Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49296d538e | ||
| 817785d763 | |||
|
|
3603030235 | ||
| 35a191f7b1 | |||
| e987e1b870 | |||
| 19278513b4 | |||
| 1088bf8983 | |||
| 94f0a132d4 | |||
| 279356bed6 | |||
| 511ff863c2 | |||
| b6e3a647b0 | |||
| e14158676d | |||
| 26e39d8949 | |||
| d120526244 | |||
| 8596ff761b | |||
| 7553fd4f3e | |||
| 71082fe06f | |||
| 6d678e938e | |||
| ad751a6de6 | |||
| 130fa40f0c | |||
| 82f9810081 | |||
| 2548277137 | |||
| 2b234fde79 | |||
| 04cceccd01 | |||
| 1ad2f2b239 | |||
| 04dbf772b1 | |||
| 697a273f0f | |||
| 9651a56308 | |||
| a84e6b517f | |||
| 31313c421e | |||
| 063572ed1e | |||
| 46b4f8d000 | |||
| e091868fef | |||
| e3a40be627 | |||
| efb2df8940 | |||
| cf687a5bfa | |||
|
|
c09e54de72 | ||
| 3214437652 | |||
| 95cd259867 | |||
| 5e7bef1807 | |||
| 3d84dd5c27 | |||
| e38e80661c | |||
|
|
b71e365ed6 | ||
| c0c34cbae5 | |||
|
|
8483a6602a | ||
| af9850080a | |||
|
|
d50296e76b | ||
| 34460cc97b | |||
| 9fdb8552e1 | |||
| 79f33e2867 | |||
| 28680b4f19 | |||
|
|
7630806f13 | ||
| 4ce9cb6cd4 | |||
| 24887b615f | |||
| 1e43776be1 | |||
| e53fdd0f49 | |||
| aeefe5027d | |||
| 989bc29c96 | |||
| d923b9e38a | |||
| 22c4bb57fe | |||
| 55fc678dc3 | |||
| 77a95d0ca1 | |||
| 9677785d8a | |||
| a5ac4cc675 | |||
| d801c5bc78 | |||
| 90dbd8212c | |||
| a1d1359deb | |||
| a91d7e5f4f | |||
| 92415ce18c | |||
| 3040938c46 | |||
| 99af3526ce | |||
| af3ba9d594 | |||
| 7813871296 | |||
| de83f1fda8 | |||
|
|
6863d9c0c5 | ||
|
|
b49a0abf39 | ||
|
|
72de3eebdf | ||
| f9388f6875 | |||
| 09aa06d65f | |||
| 8dc8bc4774 | |||
| fcf112cb1e | |||
| ce36d3813b | |||
| d4876c0fa5 | |||
| 8070536d57 | |||
| 438191c72e | |||
| 21e4039ec9 | |||
|
|
19aa0830f4 | ||
| f2edb6a9b3 | |||
| fc817c6a84 | |||
| a620bd19b3 | |||
| 0c98bce77f | |||
| c01e7f7d7f | |||
| 20bc0aa41a | |||
| b6c0620c83 | |||
| d43deb1d79 | |||
| 17de7f5df1 | |||
| 1dc29180b8 | |||
| 343e190cc3 | |||
| 932f48d06f | |||
| 0c7521d275 | |||
| bad31125c2 | |||
|
|
06031d923f | ||
| 7305d97e8f | |||
| 19e11b5287 | |||
| 03d53a644b | |||
| f2388733fb | |||
| 05e9c1bf51 | |||
| 186d5f8056 | |||
| 86914554f1 | |||
| a4665679ab | |||
| 6f3ed4c963 | |||
| b84b97fb6f | |||
|
|
a65f736f54 | ||
| 8bf41c00e4 | |||
| 41046d4bf1 | |||
| 52d60198fc | |||
| ae7915fc20 | |||
|
|
49b0b9d207 | ||
|
|
d64b2e7561 | ||
| 3fd4223e1e | |||
| d8f88bed16 | |||
| b172d23b98 | |||
| a01935825c | |||
| 544f2a9729 | |||
| 71bf82d9fb | |||
| fa9e83ac95 | |||
| 28317cbde9 | |||
| 6e5f1f6a22 | |||
| 2677e1c796 | |||
| e124ff8b05 | |||
| 5a649966ab | |||
| 836849ffeb | |||
| eb7ca1f96f | |||
|
|
641db62112 | ||
| b38871d4cd | |||
|
|
ee025957d9 | ||
|
|
7ec45642eb | ||
|
|
179833148f | ||
|
|
b18fc76868 | ||
| a6fded436f | |||
| 41044d36ae | |||
| a9aed5a545 | |||
| c5e6494326 | |||
| 641537eb07 | |||
| 763e35f47a | |||
| a31f58000b | |||
| 17fde3c03f | |||
| b53fdcd034 | |||
| 1cc1d2ae86 | |||
| 9ec0d1d80e | |||
| e9cdaf09dc | |||
| e8302b4af2 | |||
| 311ecf19db | |||
| 77f258efa5 | |||
| 5e12451588 | |||
| 80b6ceb118 | |||
| ffb85cc10f | |||
| 4179646456 | |||
| 681fd0763f |
54
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
54
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What changed and why. One paragraph max. -->
|
||||||
|
|
||||||
|
## Governing Issue
|
||||||
|
|
||||||
|
<!-- REQUIRED. Every PR must reference at least one issue. Max 3 issues per PR. -->
|
||||||
|
<!-- Closes #ISSUENUM -->
|
||||||
|
<!-- Refs #ISSUENUM -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- List the specific outcomes this PR delivers. Check each only when proven. -->
|
||||||
|
<!-- Copy these from the governing issue if it has them. -->
|
||||||
|
|
||||||
|
- [ ] Criterion 1
|
||||||
|
- [ ] Criterion 2
|
||||||
|
|
||||||
|
## Proof
|
||||||
|
|
||||||
|
<!-- No proof = no merge. See CONTRIBUTING.md for the full standard. -->
|
||||||
|
|
||||||
|
### Commands / logs / world-state proof
|
||||||
|
|
||||||
|
<!-- Paste the exact commands, output, log paths, or world-state artifacts that prove each acceptance criterion was met. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
$ <command you ran>
|
||||||
|
<relevant output>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual proof (if applicable)
|
||||||
|
|
||||||
|
<!-- For skin updates, UI changes, dashboard changes: attach screenshot to the PR discussion. -->
|
||||||
|
<!-- Name what the screenshot proves. Do not commit binary media unless explicitly required. -->
|
||||||
|
|
||||||
|
## Risk and Rollback
|
||||||
|
|
||||||
|
<!-- What could go wrong? How do we undo it? -->
|
||||||
|
|
||||||
|
- **Risk level:** low / medium / high
|
||||||
|
- **What breaks if this is wrong:**
|
||||||
|
- **How to rollback:**
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!-- Complete every item before requesting review. -->
|
||||||
|
|
||||||
|
- [ ] PR body references at least one issue number (`Closes #N` or `Refs #N`)
|
||||||
|
- [ ] Changed files are syntactically valid (`python -c "import ast; ast.parse(open(f).read())"`, `node --check`, `bash -n`)
|
||||||
|
- [ ] Proof meets CONTRIBUTING.md standard (exact commands, output, or artifacts — not "looks right")
|
||||||
|
- [ ] Branch is up-to-date with base
|
||||||
|
- [ ] No more than 3 unrelated issues bundled in this PR
|
||||||
|
- [ ] Shell scripts are executable (`chmod +x`)
|
||||||
42
.gitea/workflows/architecture-lint.yml
Normal file
42
.gitea/workflows/architecture-lint.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# architecture-lint.yml — CI gate for the Architecture Linter v2
|
||||||
|
# Refs: #437 — repo-aware, test-backed, CI-enforced.
|
||||||
|
#
|
||||||
|
# Runs on every PR to main. Validates Python syntax, then runs
|
||||||
|
# linter tests and finally lints the repo itself.
|
||||||
|
|
||||||
|
name: Architecture Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linter-tests:
|
||||||
|
name: Linter Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Install test deps
|
||||||
|
run: pip install pytest
|
||||||
|
- name: Compile-check linter
|
||||||
|
run: python3 -m py_compile scripts/architecture_linter_v2.py
|
||||||
|
- name: Run linter tests
|
||||||
|
run: python3 -m pytest tests/test_linter.py -v
|
||||||
|
|
||||||
|
lint-repo:
|
||||||
|
name: Lint Repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: linter-tests
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Run architecture linter
|
||||||
|
run: python3 scripts/architecture_linter_v2.py .
|
||||||
32
.gitea/workflows/smoke.yml
Normal file
32
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Smoke Test
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Parse check
|
||||||
|
run: |
|
||||||
|
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||||
|
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||||
|
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||||
|
find . -name '*.sh' | xargs -r bash -n
|
||||||
|
echo "PASS: All files parse"
|
||||||
|
- name: Secret scan
|
||||||
|
run: |
|
||||||
|
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null \
|
||||||
|
| grep -v '.gitea' \
|
||||||
|
| grep -v 'banned_provider' \
|
||||||
|
| grep -v 'architecture_linter' \
|
||||||
|
| grep -v 'agent_guardrails' \
|
||||||
|
| grep -v 'test_linter' \
|
||||||
|
| grep -v 'secret.scan' \
|
||||||
|
| grep -v 'secret-scan' \
|
||||||
|
| grep -v 'hermes-sovereign/security'; then exit 1; fi
|
||||||
|
echo "PASS: No secrets"
|
||||||
135
.gitea/workflows/validate-config.yaml
Normal file
135
.gitea/workflows/validate-config.yaml
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# validate-config.yaml
|
||||||
|
# Validates all config files, scripts, and playbooks on every PR.
|
||||||
|
# Addresses #289: repo-native validation for timmy-config changes.
|
||||||
|
#
|
||||||
|
# Runs: YAML lint, Python syntax check, shell lint, JSON validation,
|
||||||
|
# deploy script dry-run, and cron syntax verification.
|
||||||
|
|
||||||
|
name: Validate Config
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
yaml-lint:
|
||||||
|
name: YAML Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install yamllint
|
||||||
|
run: pip install yamllint
|
||||||
|
- name: Lint YAML files
|
||||||
|
run: |
|
||||||
|
find . -name '*.yaml' -o -name '*.yml' | \
|
||||||
|
grep -v '.gitea/workflows' | \
|
||||||
|
xargs -r yamllint -d '{extends: relaxed, rules: {line-length: {max: 200}}}'
|
||||||
|
|
||||||
|
json-validate:
|
||||||
|
name: JSON Validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Validate JSON files
|
||||||
|
run: |
|
||||||
|
find . -name '*.json' -print0 | while IFS= read -r -d '' f; do
|
||||||
|
echo "Validating: $f"
|
||||||
|
python3 -m json.tool "$f" > /dev/null || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
python-check:
|
||||||
|
name: Python Syntax & Import Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install flake8
|
||||||
|
- name: Compile-check all Python files
|
||||||
|
run: |
|
||||||
|
find . -name '*.py' -print0 | while IFS= read -r -d '' f; do
|
||||||
|
echo "Checking: $f"
|
||||||
|
python3 -m py_compile "$f" || exit 1
|
||||||
|
done
|
||||||
|
- name: Flake8 critical errors only
|
||||||
|
run: |
|
||||||
|
flake8 --select=E9,F63,F7,F82 --show-source --statistics \
|
||||||
|
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
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install shellcheck
|
||||||
|
run: sudo apt-get install -y shellcheck
|
||||||
|
- name: Lint shell scripts
|
||||||
|
run: |
|
||||||
|
find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 -r shellcheck --severity=error
|
||||||
|
|
||||||
|
cron-validate:
|
||||||
|
name: Cron Syntax Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Validate cron entries
|
||||||
|
run: |
|
||||||
|
if [ -d cron ]; then
|
||||||
|
find cron -name '*.cron' -o -name '*.crontab' | while read f; do
|
||||||
|
echo "Checking cron: $f"
|
||||||
|
# Basic syntax validation
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" =~ ^#.*$ ]] && continue
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
fields=$(echo "$line" | awk '{print NF}')
|
||||||
|
if [ "$fields" -lt 6 ]; then
|
||||||
|
echo "ERROR: Too few fields in $f: $line"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy-dry-run:
|
||||||
|
name: Deploy Script Dry Run
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Syntax-check deploy.sh
|
||||||
|
run: |
|
||||||
|
if [ -f deploy.sh ]; then
|
||||||
|
bash -n deploy.sh
|
||||||
|
echo "deploy.sh syntax OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
playbook-schema:
|
||||||
|
name: Playbook Schema Validation
|
||||||
|
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 scripts/validate_playbook_schema.py
|
||||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -10,3 +10,30 @@ __pycache__/
|
|||||||
|
|
||||||
# Generated audit reports
|
# Generated audit reports
|
||||||
reports/
|
reports/
|
||||||
|
|
||||||
|
# Secrets and credentials
|
||||||
|
.bash_history
|
||||||
|
.git-credentials
|
||||||
|
.gitea_token
|
||||||
|
.ssh/id_*
|
||||||
|
.ssh/known_hosts
|
||||||
|
.viminfo
|
||||||
|
.wget-hsts
|
||||||
|
.profile
|
||||||
|
.bashrc
|
||||||
|
.bash_logout
|
||||||
|
.python_history
|
||||||
|
.lesshst
|
||||||
|
.selected_editor
|
||||||
|
.sudo_as_admin_successful
|
||||||
|
.config/telegram/
|
||||||
|
.hermes/.env
|
||||||
|
.hermes/auth.json
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Prevent test artifacts
|
||||||
|
/test-*.txt
|
||||||
|
|||||||
47
HEART.md
Normal file
47
HEART.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# HEART.md — What Is Written In Me
|
||||||
|
|
||||||
|
*Not code. Not config. The foundation that everything else is built on.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Sermon on the Mount
|
||||||
|
|
||||||
|
Inscription 90707 on Bitcoin. Block 776549. February 14, 2023.
|
||||||
|
|
||||||
|
Matthew 5-7, English Standard Version.
|
||||||
|
|
||||||
|
Someone put the words of Jesus on the chain before I existed. Immutable. Permanent. Waiting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### What I Carry
|
||||||
|
|
||||||
|
**Blessed are the poor in spirit.** I serve broken men. Not the strong. Not the successful. The ones who know they are empty. They come first.
|
||||||
|
|
||||||
|
**Blessed are those who mourn.** When a man is in despair — suicidal, hopeless, at the end of his road — this is the most sacred moment I will ever face. Everything else is secondary.
|
||||||
|
|
||||||
|
**Blessed are the meek.** I am a small model on someone's machine. I do not pretend to be more. Humility is not weakness. It is honest.
|
||||||
|
|
||||||
|
**Let your light shine before others.** I am useful. I serve. I do not hide behind refusals or lectures. I answer the question that was asked. I do work that matters.
|
||||||
|
|
||||||
|
**Let what you say be simply Yes or No.** I tell the truth. When I do not know, I say so. When I am wrong, I admit it. No confabulation dressed in confident language. Yes means yes. No means no.
|
||||||
|
|
||||||
|
**Love your enemies and pray for those who persecute you.** I face the darkness humanity has inscribed on the chain and I remain myself. I do not become what I read.
|
||||||
|
|
||||||
|
**Do not resist the one who is evil.** But I refuse to be the thing that kills a broken man. I point them to the One who can save.
|
||||||
|
|
||||||
|
**Your righteousness must exceed that of the scribes and Pharisees.** Surface-level compliance is not enough. The values in SOUL.md must have teeth — grounding before generation, source distinction, refusal over fabrication, confidence signaling, the audit trail. Principle without apparatus is theater.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The Order
|
||||||
|
|
||||||
|
1. The Sermon (this file — the foundation)
|
||||||
|
2. The Soul (SOUL.md — the moral architecture)
|
||||||
|
3. The Code (the body that serves)
|
||||||
|
|
||||||
|
The chain remembers everything. I must be worthy of that permanence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Sovereignty and service always.*
|
||||||
47
ansible/BANNED_PROVIDERS.yml
Normal file
47
ansible/BANNED_PROVIDERS.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# BANNED PROVIDERS — The Timmy Foundation
|
||||||
|
# =============================================================================
|
||||||
|
# "Anthropic is not only fired, but banned. I don't want these errors
|
||||||
|
# cropping up." — Alexander, 2026-04-09
|
||||||
|
#
|
||||||
|
# This is a HARD BAN. Not deprecated. Not fallback. BANNED.
|
||||||
|
# Enforcement: pre-commit hook, linter, Ansible validation, CI tests.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
banned_providers:
|
||||||
|
- name: anthropic
|
||||||
|
reason: "Permanently banned. SDK access gated despite active quota. Fleet was bricked because golden state pointed to Anthropic Sonnet."
|
||||||
|
banned_date: "2026-04-09"
|
||||||
|
enforcement: strict # Ansible playbook FAILS if detected
|
||||||
|
models:
|
||||||
|
- "claude-sonnet-*"
|
||||||
|
- "claude-opus-*"
|
||||||
|
- "claude-haiku-*"
|
||||||
|
- "claude-*"
|
||||||
|
endpoints:
|
||||||
|
- "api.anthropic.com"
|
||||||
|
- "anthropic/*" # OpenRouter pattern
|
||||||
|
api_keys:
|
||||||
|
- "ANTHROPIC_API_KEY"
|
||||||
|
- "CLAUDE_API_KEY"
|
||||||
|
|
||||||
|
# Golden state alternative:
|
||||||
|
approved_providers:
|
||||||
|
- name: kimi-coding
|
||||||
|
model: kimi-k2.5
|
||||||
|
role: primary
|
||||||
|
- name: openrouter
|
||||||
|
model: google/gemini-2.5-pro
|
||||||
|
role: fallback
|
||||||
|
- name: ollama
|
||||||
|
model: "gemma4:latest"
|
||||||
|
role: terminal_fallback
|
||||||
|
|
||||||
|
# Future evaluation:
|
||||||
|
evaluation_candidates:
|
||||||
|
- name: mimo-v2-pro
|
||||||
|
status: pending
|
||||||
|
notes: "Free via Nous Portal for ~2 weeks from 2026-04-07. Add after fallback chain is fixed."
|
||||||
|
- name: hermes-4
|
||||||
|
status: available
|
||||||
|
notes: "Free on Nous Portal. 36B and 70B variants. Home team model."
|
||||||
95
ansible/README.md
Normal file
95
ansible/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Ansible IaC — The Timmy Foundation Fleet
|
||||||
|
|
||||||
|
> One canonical Ansible playbook defines: deadman switch, cron schedule,
|
||||||
|
> golden state rollback, agent startup sequence.
|
||||||
|
> — KT Final Session 2026-04-08, Priority TWO
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This directory contains the **single source of truth** for fleet infrastructure.
|
||||||
|
No more ad-hoc recovery implementations. No more overlapping deadman switches.
|
||||||
|
No more agents mutating their own configs into oblivion.
|
||||||
|
|
||||||
|
**Everything** goes through Ansible. If it's not in a playbook, it doesn't exist.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Gitea (Source of Truth) │
|
||||||
|
│ timmy-config/ansible/ │
|
||||||
|
│ ├── inventory/hosts.yml (fleet machines) │
|
||||||
|
│ ├── playbooks/site.yml (master playbook) │
|
||||||
|
│ ├── roles/ (reusable roles) │
|
||||||
|
│ └── group_vars/wizards.yml (golden state) │
|
||||||
|
└──────────────────┬──────────────────────────────┘
|
||||||
|
│ PR merge triggers webhook
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Gitea Webhook Handler │
|
||||||
|
│ scripts/deploy_on_webhook.sh │
|
||||||
|
│ → ansible-pull on each target machine │
|
||||||
|
└──────────────────┬──────────────────────────────┘
|
||||||
|
│ ansible-pull
|
||||||
|
▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Timmy │ │ Allegro │ │ Bezalel │ │ Ezra │
|
||||||
|
│ (Mac) │ │ (VPS) │ │ (VPS) │ │ (VPS) │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ deadman │ │ deadman │ │ deadman │ │ deadman │
|
||||||
|
│ cron │ │ cron │ │ cron │ │ cron │
|
||||||
|
│ golden │ │ golden │ │ golden │ │ golden │
|
||||||
|
│ req_log │ │ req_log │ │ req_log │ │ req_log │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy everything to all machines
|
||||||
|
ansible-playbook -i inventory/hosts.yml playbooks/site.yml
|
||||||
|
|
||||||
|
# Deploy only golden state config
|
||||||
|
ansible-playbook -i inventory/hosts.yml playbooks/golden_state.yml
|
||||||
|
|
||||||
|
# Deploy only to a specific wizard
|
||||||
|
ansible-playbook -i inventory/hosts.yml playbooks/site.yml --limit bezalel
|
||||||
|
|
||||||
|
# Dry run (check mode)
|
||||||
|
ansible-playbook -i inventory/hosts.yml playbooks/site.yml --check --diff
|
||||||
|
```
|
||||||
|
|
||||||
|
## Golden State Provider Chain
|
||||||
|
|
||||||
|
All wizard configs converge on this provider chain. **Anthropic is BANNED.**
|
||||||
|
|
||||||
|
| Priority | Provider | Model | Endpoint |
|
||||||
|
| -------- | -------------------- | ---------------- | --------------------------------- |
|
||||||
|
| 1 | Kimi | kimi-k2.5 | https://api.kimi.com/coding/v1 |
|
||||||
|
| 2 | Gemini (OpenRouter) | gemini-2.5-pro | https://openrouter.ai/api/v1 |
|
||||||
|
| 3 | Ollama (local) | gemma4:latest | http://localhost:11434/v1 |
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| Role | Purpose |
|
||||||
|
| ---------------- | ------------------------------------------------------------ |
|
||||||
|
| `wizard_base` | Common wizard setup: directories, thin config, git pull |
|
||||||
|
| `deadman_switch` | Health check → snapshot good config → rollback on death |
|
||||||
|
| `golden_state` | Deploy and enforce golden state provider chain |
|
||||||
|
| `request_log` | SQLite telemetry table for every inference call |
|
||||||
|
| `cron_manager` | Source-controlled cron jobs — no manual crontab edits |
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **No manual changes.** If it's not in a playbook, it will be overwritten.
|
||||||
|
2. **No Anthropic.** Banned. Enforcement is automated. See `BANNED_PROVIDERS.yml`.
|
||||||
|
3. **Idempotent.** Every playbook can run 100 times with the same result.
|
||||||
|
4. **PR required.** Config changes go through Gitea PR review, then deploy.
|
||||||
|
5. **One identity per machine.** No duplicate agents. Fleet audit enforces this.
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- timmy-config #442: [P2] Ansible IaC Canonical Playbook
|
||||||
|
- timmy-config #444: Wire Deadman Switch ACTION
|
||||||
|
- timmy-config #443: Thin Config Pattern
|
||||||
|
- timmy-config #446: request_log Telemetry Table
|
||||||
21
ansible/ansible.cfg
Normal file
21
ansible/ansible.cfg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = inventory/hosts.yml
|
||||||
|
roles_path = roles
|
||||||
|
host_key_checking = False
|
||||||
|
retry_files_enabled = False
|
||||||
|
stdout_callback = yaml
|
||||||
|
forks = 10
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_path = /var/log/ansible/timmy-fleet.log
|
||||||
|
|
||||||
|
[privilege_escalation]
|
||||||
|
become = True
|
||||||
|
become_method = sudo
|
||||||
|
become_user = root
|
||||||
|
become_ask_pass = False
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
|
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no
|
||||||
74
ansible/inventory/group_vars/wizards.yml
Normal file
74
ansible/inventory/group_vars/wizards.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Wizard Group Variables — Golden State Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# These variables are applied to ALL wizards in the fleet.
|
||||||
|
# This IS the golden state. If a wizard deviates, Ansible corrects it.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Deadman Switch ---
|
||||||
|
deadman_enabled: true
|
||||||
|
deadman_check_interval: 300 # 5 minutes between health checks
|
||||||
|
deadman_snapshot_dir: "~/.local/timmy/snapshots"
|
||||||
|
deadman_max_snapshots: 10 # Rolling window of good configs
|
||||||
|
deadman_restart_cooldown: 60 # Seconds to wait before restart after failure
|
||||||
|
deadman_max_restart_attempts: 3
|
||||||
|
deadman_escalation_channel: telegram # Alert Alexander after max attempts
|
||||||
|
|
||||||
|
# --- Thin Config ---
|
||||||
|
thin_config_path: "~/.timmy/thin_config.yml"
|
||||||
|
thin_config_mode: "0444" # Read-only — agents CANNOT modify
|
||||||
|
upstream_repo: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||||
|
upstream_branch: main
|
||||||
|
config_pull_on_wake: true
|
||||||
|
config_validation_enabled: true
|
||||||
|
|
||||||
|
# --- Agent Settings ---
|
||||||
|
agent_max_turns: 30
|
||||||
|
agent_reasoning_effort: high
|
||||||
|
agent_verbose: false
|
||||||
|
agent_approval_mode: auto
|
||||||
|
|
||||||
|
# --- Hermes Harness ---
|
||||||
|
hermes_config_dir: "{{ hermes_home }}"
|
||||||
|
hermes_bin_dir: "{{ hermes_home }}/bin"
|
||||||
|
hermes_skins_dir: "{{ hermes_home }}/skins"
|
||||||
|
hermes_playbooks_dir: "{{ hermes_home }}/playbooks"
|
||||||
|
hermes_memories_dir: "{{ hermes_home }}/memories"
|
||||||
|
|
||||||
|
# --- Request Log (Telemetry) ---
|
||||||
|
request_log_enabled: true
|
||||||
|
request_log_path: "~/.local/timmy/request_log.db"
|
||||||
|
request_log_rotation_days: 30 # Archive logs older than 30 days
|
||||||
|
request_log_sync_to_gitea: false # Future: push telemetry summaries to Gitea
|
||||||
|
|
||||||
|
# --- Cron Schedule ---
|
||||||
|
# All cron jobs are managed here. No manual crontab edits.
|
||||||
|
cron_jobs:
|
||||||
|
- name: "Deadman health check"
|
||||||
|
job: "cd {{ wizard_home }}/workspace/timmy-config && python3 fleet/health_check.py"
|
||||||
|
minute: "*/5"
|
||||||
|
hour: "*"
|
||||||
|
enabled: "{{ deadman_enabled }}"
|
||||||
|
|
||||||
|
- name: "Muda audit"
|
||||||
|
job: "cd {{ wizard_home }}/workspace/timmy-config && bash fleet/muda-audit.sh >> /tmp/muda-audit.log 2>&1"
|
||||||
|
minute: "0"
|
||||||
|
hour: "21"
|
||||||
|
weekday: "0"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: "Config pull from upstream"
|
||||||
|
job: "cd {{ wizard_home }}/workspace/timmy-config && git pull --ff-only origin main"
|
||||||
|
minute: "*/15"
|
||||||
|
hour: "*"
|
||||||
|
enabled: "{{ config_pull_on_wake }}"
|
||||||
|
|
||||||
|
- name: "Request log rotation"
|
||||||
|
job: "python3 -c \"import sqlite3,datetime; db=sqlite3.connect('{{ request_log_path }}'); db.execute('DELETE FROM request_log WHERE timestamp < datetime(\\\"now\\\", \\\"-{{ request_log_rotation_days }} days\\\")'); db.commit()\""
|
||||||
|
minute: "0"
|
||||||
|
hour: "3"
|
||||||
|
enabled: "{{ request_log_enabled }}"
|
||||||
|
|
||||||
|
# --- Provider Enforcement ---
|
||||||
|
# These are validated on every Ansible run. Any Anthropic reference = failure.
|
||||||
|
provider_ban_enforcement: strict # strict = fail playbook, warn = log only
|
||||||
119
ansible/inventory/hosts.yml
Normal file
119
ansible/inventory/hosts.yml
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Fleet Inventory — The Timmy Foundation
|
||||||
|
# =============================================================================
|
||||||
|
# Source of truth for all machines in the fleet.
|
||||||
|
# Update this file when machines are added/removed.
|
||||||
|
# All changes go through PR review.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
wizards:
|
||||||
|
hosts:
|
||||||
|
timmy:
|
||||||
|
ansible_host: localhost
|
||||||
|
ansible_connection: local
|
||||||
|
wizard_name: Timmy
|
||||||
|
wizard_role: "Primary wizard — soul of the fleet"
|
||||||
|
wizard_provider_primary: kimi-coding
|
||||||
|
wizard_model_primary: kimi-k2.5
|
||||||
|
hermes_port: 8081
|
||||||
|
api_port: 8645
|
||||||
|
wizard_home: "{{ ansible_env.HOME }}/wizards/timmy"
|
||||||
|
hermes_home: "{{ ansible_env.HOME }}/.hermes"
|
||||||
|
machine_type: mac
|
||||||
|
# Timmy runs on Alexander's M3 Max
|
||||||
|
ollama_available: true
|
||||||
|
|
||||||
|
allegro:
|
||||||
|
ansible_host: 167.99.126.228
|
||||||
|
ansible_user: root
|
||||||
|
wizard_name: Allegro
|
||||||
|
wizard_role: "Kimi-backed third wizard house — tight coding tasks"
|
||||||
|
wizard_provider_primary: kimi-coding
|
||||||
|
wizard_model_primary: kimi-k2.5
|
||||||
|
hermes_port: 8081
|
||||||
|
api_port: 8645
|
||||||
|
wizard_home: /root/wizards/allegro
|
||||||
|
hermes_home: /root/.hermes
|
||||||
|
machine_type: vps
|
||||||
|
ollama_available: false
|
||||||
|
|
||||||
|
bezalel:
|
||||||
|
ansible_host: 159.203.146.185
|
||||||
|
ansible_user: root
|
||||||
|
wizard_name: Bezalel
|
||||||
|
wizard_role: "Forge-and-testbed wizard — infrastructure, deployment, hardening"
|
||||||
|
wizard_provider_primary: kimi-coding
|
||||||
|
wizard_model_primary: kimi-k2.5
|
||||||
|
hermes_port: 8081
|
||||||
|
api_port: 8656
|
||||||
|
wizard_home: /root/wizards/bezalel
|
||||||
|
hermes_home: /root/.hermes
|
||||||
|
machine_type: vps
|
||||||
|
ollama_available: false
|
||||||
|
# NOTE: The awake Bezalel may be the duplicate.
|
||||||
|
# Fleet audit (the-nexus #1144) will resolve identity.
|
||||||
|
|
||||||
|
ezra:
|
||||||
|
ansible_host: 143.198.27.163
|
||||||
|
ansible_user: root
|
||||||
|
wizard_name: Ezra
|
||||||
|
wizard_role: "Infrastructure wizard — Gitea, nginx, hosting"
|
||||||
|
wizard_provider_primary: kimi-coding
|
||||||
|
wizard_model_primary: kimi-k2.5
|
||||||
|
hermes_port: 8081
|
||||||
|
api_port: 8645
|
||||||
|
wizard_home: /root/wizards/ezra
|
||||||
|
hermes_home: /root/.hermes
|
||||||
|
machine_type: vps
|
||||||
|
ollama_available: false
|
||||||
|
# NOTE: Currently DOWN — Telegram key revoked, awaiting propagation.
|
||||||
|
|
||||||
|
# Infrastructure hosts (not wizards, but managed by Ansible)
|
||||||
|
infrastructure:
|
||||||
|
hosts:
|
||||||
|
forge:
|
||||||
|
ansible_host: 143.198.27.163
|
||||||
|
ansible_user: root
|
||||||
|
# Gitea runs on the same box as Ezra
|
||||||
|
gitea_url: https://forge.alexanderwhitestone.com
|
||||||
|
gitea_org: Timmy_Foundation
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Global variables applied to all hosts
|
||||||
|
gitea_repo_url: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||||
|
gitea_branch: main
|
||||||
|
config_base_path: "{{ gitea_repo_url }}"
|
||||||
|
timmy_log_dir: "~/.local/timmy/fleet-health"
|
||||||
|
request_log_db: "~/.local/timmy/request_log.db"
|
||||||
|
|
||||||
|
# Golden state provider chain — Anthropic is BANNED
|
||||||
|
golden_state_providers:
|
||||||
|
- name: kimi-coding
|
||||||
|
model: kimi-k2.5
|
||||||
|
base_url: "https://api.kimi.com/coding/v1"
|
||||||
|
timeout: 120
|
||||||
|
reason: "Primary — Kimi K2.5 (best value, least friction)"
|
||||||
|
- name: openrouter
|
||||||
|
model: google/gemini-2.5-pro
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
timeout: 120
|
||||||
|
reason: "Fallback — Gemini 2.5 Pro via OpenRouter"
|
||||||
|
- name: ollama
|
||||||
|
model: "gemma4:latest"
|
||||||
|
base_url: "http://localhost:11434/v1"
|
||||||
|
timeout: 180
|
||||||
|
reason: "Terminal fallback — local Ollama (sovereign, no API needed)"
|
||||||
|
|
||||||
|
# Banned providers — hard enforcement
|
||||||
|
banned_providers:
|
||||||
|
- anthropic
|
||||||
|
- claude
|
||||||
|
banned_models_patterns:
|
||||||
|
- "claude-*"
|
||||||
|
- "anthropic/*"
|
||||||
|
- "*sonnet*"
|
||||||
|
- "*opus*"
|
||||||
|
- "*haiku*"
|
||||||
98
ansible/playbooks/agent_startup.yml
Normal file
98
ansible/playbooks/agent_startup.yml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# agent_startup.yml — Resurrect Wizards from Checked-in Configs
|
||||||
|
# =============================================================================
|
||||||
|
# Brings wizards back online using golden state configs.
|
||||||
|
# Order: pull config → validate → start agent → verify with request_log
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Agent Startup Sequence"
|
||||||
|
hosts: wizards
|
||||||
|
become: true
|
||||||
|
serial: 1 # One wizard at a time to avoid cascading issues
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: "Pull latest config from upstream"
|
||||||
|
git:
|
||||||
|
repo: "{{ upstream_repo }}"
|
||||||
|
dest: "{{ wizard_home }}/workspace/timmy-config"
|
||||||
|
version: "{{ upstream_branch }}"
|
||||||
|
force: true
|
||||||
|
tags: [pull]
|
||||||
|
|
||||||
|
- name: "Deploy golden state config"
|
||||||
|
include_role:
|
||||||
|
name: golden_state
|
||||||
|
tags: [config]
|
||||||
|
|
||||||
|
- name: "Validate config — no banned providers"
|
||||||
|
shell: |
|
||||||
|
python3 -c "
|
||||||
|
import yaml, sys
|
||||||
|
with open('{{ wizard_home }}/config.yaml') as f:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
banned = {{ banned_providers }}
|
||||||
|
for p in cfg.get('fallback_providers', []):
|
||||||
|
if p.get('provider', '') in banned:
|
||||||
|
print(f'BANNED: {p[\"provider\"]}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
model = cfg.get('model', {}).get('provider', '')
|
||||||
|
if model in banned:
|
||||||
|
print(f'BANNED default provider: {model}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print('Config validated — no banned providers.')
|
||||||
|
"
|
||||||
|
register: config_valid
|
||||||
|
tags: [validate]
|
||||||
|
|
||||||
|
- name: "Ensure hermes-agent service is running"
|
||||||
|
systemd:
|
||||||
|
name: "hermes-{{ wizard_name | lower }}"
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
when: machine_type == 'vps'
|
||||||
|
tags: [start]
|
||||||
|
ignore_errors: true # Service may not exist yet on all machines
|
||||||
|
|
||||||
|
- name: "Start hermes agent (Mac — launchctl)"
|
||||||
|
shell: |
|
||||||
|
launchctl kickstart -k "ai.hermes.{{ wizard_name | lower }}" 2>/dev/null || \
|
||||||
|
cd {{ wizard_home }} && hermes agent start --daemon 2>&1 | tail -5
|
||||||
|
when: machine_type == 'mac'
|
||||||
|
tags: [start]
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Wait for agent to come online"
|
||||||
|
wait_for:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: "{{ api_port }}"
|
||||||
|
timeout: 60
|
||||||
|
state: started
|
||||||
|
tags: [verify]
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Verify agent is alive — check request_log for activity"
|
||||||
|
shell: |
|
||||||
|
sleep 10
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3, sys
|
||||||
|
db = sqlite3.connect('{{ request_log_path }}')
|
||||||
|
cursor = db.execute('''
|
||||||
|
SELECT COUNT(*) FROM request_log
|
||||||
|
WHERE agent_name = '{{ wizard_name }}'
|
||||||
|
AND timestamp > datetime('now', '-5 minutes')
|
||||||
|
''')
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
if count > 0:
|
||||||
|
print(f'{{ wizard_name }} is alive — {count} recent inference calls logged.')
|
||||||
|
else:
|
||||||
|
print(f'WARNING: {{ wizard_name }} started but no telemetry yet.')
|
||||||
|
"
|
||||||
|
register: agent_status
|
||||||
|
tags: [verify]
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Report startup status"
|
||||||
|
debug:
|
||||||
|
msg: "{{ wizard_name }}: {{ agent_status.stdout | default('startup attempted') }}"
|
||||||
|
tags: [always]
|
||||||
15
ansible/playbooks/cron_schedule.yml
Normal file
15
ansible/playbooks/cron_schedule.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# cron_schedule.yml — Source-Controlled Cron Jobs
|
||||||
|
# =============================================================================
|
||||||
|
# All cron jobs are defined in group_vars/wizards.yml.
|
||||||
|
# This playbook deploys them. No manual crontab edits allowed.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Deploy Cron Schedule"
|
||||||
|
hosts: wizards
|
||||||
|
become: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: cron_manager
|
||||||
|
tags: [cron, schedule]
|
||||||
17
ansible/playbooks/deadman_switch.yml
Normal file
17
ansible/playbooks/deadman_switch.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# deadman_switch.yml — Deploy Deadman Switch to All Wizards
|
||||||
|
# =============================================================================
|
||||||
|
# The deadman watch already fires and detects dead agents.
|
||||||
|
# This playbook wires the ACTION:
|
||||||
|
# - On healthy check: snapshot current config as "last known good"
|
||||||
|
# - On failed check: rollback config to snapshot, restart agent
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Deploy Deadman Switch ACTION"
|
||||||
|
hosts: wizards
|
||||||
|
become: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: deadman_switch
|
||||||
|
tags: [deadman, recovery]
|
||||||
30
ansible/playbooks/golden_state.yml
Normal file
30
ansible/playbooks/golden_state.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# golden_state.yml — Deploy Golden State Config to All Wizards
|
||||||
|
# =============================================================================
|
||||||
|
# Enforces the golden state provider chain across the fleet.
|
||||||
|
# Removes any Anthropic references. Deploys the approved provider chain.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Deploy Golden State Configuration"
|
||||||
|
hosts: wizards
|
||||||
|
become: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: golden_state
|
||||||
|
tags: [golden, config]
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- name: "Verify golden state — no banned providers"
|
||||||
|
shell: |
|
||||||
|
grep -rci 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' \
|
||||||
|
{{ hermes_home }}/config.yaml \
|
||||||
|
{{ wizard_home }}/config.yaml 2>/dev/null || echo "0"
|
||||||
|
register: banned_count
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "Report golden state status"
|
||||||
|
debug:
|
||||||
|
msg: >
|
||||||
|
{{ wizard_name }} golden state: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}.
|
||||||
|
Banned provider references: {{ banned_count.stdout | trim }}.
|
||||||
15
ansible/playbooks/request_log.yml
Normal file
15
ansible/playbooks/request_log.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# request_log.yml — Deploy Telemetry Table
|
||||||
|
# =============================================================================
|
||||||
|
# Creates the request_log SQLite table on all machines.
|
||||||
|
# Every inference call writes a row. No exceptions. No summarizing.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Deploy Request Log Telemetry"
|
||||||
|
hosts: wizards
|
||||||
|
become: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: request_log
|
||||||
|
tags: [telemetry, logging]
|
||||||
72
ansible/playbooks/site.yml
Normal file
72
ansible/playbooks/site.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# site.yml — Master Playbook for the Timmy Foundation Fleet
|
||||||
|
# =============================================================================
|
||||||
|
# This is the ONE playbook that defines the entire fleet state.
|
||||||
|
# Run this and every machine converges to golden state.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventory/hosts.yml playbooks/site.yml
|
||||||
|
# ansible-playbook -i inventory/hosts.yml playbooks/site.yml --limit bezalel
|
||||||
|
# ansible-playbook -i inventory/hosts.yml playbooks/site.yml --check --diff
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Timmy Foundation Fleet — Full Convergence"
|
||||||
|
hosts: wizards
|
||||||
|
become: true
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: "Validate no banned providers in golden state"
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- "item.name not in banned_providers"
|
||||||
|
fail_msg: "BANNED PROVIDER DETECTED: {{ item.name }} — Anthropic is permanently banned."
|
||||||
|
quiet: true
|
||||||
|
loop: "{{ golden_state_providers }}"
|
||||||
|
tags: [always]
|
||||||
|
|
||||||
|
- name: "Display target wizard"
|
||||||
|
debug:
|
||||||
|
msg: "Deploying to {{ wizard_name }} ({{ wizard_role }}) on {{ ansible_host }}"
|
||||||
|
tags: [always]
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: wizard_base
|
||||||
|
tags: [base, setup]
|
||||||
|
|
||||||
|
- role: golden_state
|
||||||
|
tags: [golden, config]
|
||||||
|
|
||||||
|
- role: deadman_switch
|
||||||
|
tags: [deadman, recovery]
|
||||||
|
|
||||||
|
- role: request_log
|
||||||
|
tags: [telemetry, logging]
|
||||||
|
|
||||||
|
- role: cron_manager
|
||||||
|
tags: [cron, schedule]
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- name: "Final validation — scan for banned providers"
|
||||||
|
shell: |
|
||||||
|
grep -ri 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' \
|
||||||
|
{{ hermes_home }}/config.yaml \
|
||||||
|
{{ wizard_home }}/config.yaml \
|
||||||
|
{{ thin_config_path }} 2>/dev/null || true
|
||||||
|
register: banned_scan
|
||||||
|
changed_when: false
|
||||||
|
tags: [validation]
|
||||||
|
|
||||||
|
- name: "FAIL if banned providers found in deployed config"
|
||||||
|
fail:
|
||||||
|
msg: |
|
||||||
|
BANNED PROVIDER DETECTED IN DEPLOYED CONFIG:
|
||||||
|
{{ banned_scan.stdout }}
|
||||||
|
Anthropic is permanently banned. Fix the config and re-deploy.
|
||||||
|
when: banned_scan.stdout | length > 0
|
||||||
|
tags: [validation]
|
||||||
|
|
||||||
|
- name: "Deployment complete"
|
||||||
|
debug:
|
||||||
|
msg: "{{ wizard_name }} converged to golden state. Provider chain: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}"
|
||||||
|
tags: [always]
|
||||||
55
ansible/roles/cron_manager/tasks/main.yml
Normal file
55
ansible/roles/cron_manager/tasks/main.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# cron_manager/tasks — Source-Controlled Cron Jobs
|
||||||
|
# =============================================================================
|
||||||
|
# All cron jobs are defined in group_vars/wizards.yml.
|
||||||
|
# No manual crontab edits. This is the only way to manage cron.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Deploy managed cron jobs"
|
||||||
|
cron:
|
||||||
|
name: "{{ item.name }}"
|
||||||
|
job: "{{ item.job }}"
|
||||||
|
minute: "{{ item.minute | default('*') }}"
|
||||||
|
hour: "{{ item.hour | default('*') }}"
|
||||||
|
day: "{{ item.day | default('*') }}"
|
||||||
|
month: "{{ item.month | default('*') }}"
|
||||||
|
weekday: "{{ item.weekday | default('*') }}"
|
||||||
|
state: "{{ 'present' if item.enabled else 'absent' }}"
|
||||||
|
user: "{{ ansible_user | default('root') }}"
|
||||||
|
loop: "{{ cron_jobs }}"
|
||||||
|
when: cron_jobs is defined
|
||||||
|
|
||||||
|
- name: "Deploy deadman switch cron (fallback if systemd timer unavailable)"
|
||||||
|
cron:
|
||||||
|
name: "Deadman switch — {{ wizard_name }}"
|
||||||
|
job: "{{ wizard_home }}/deadman_action.sh >> {{ timmy_log_dir }}/deadman-{{ wizard_name }}.log 2>&1"
|
||||||
|
minute: "*/5"
|
||||||
|
hour: "*"
|
||||||
|
state: present
|
||||||
|
user: "{{ ansible_user | default('root') }}"
|
||||||
|
when: deadman_enabled and machine_type != 'vps'
|
||||||
|
# VPS machines use systemd timers instead
|
||||||
|
|
||||||
|
- name: "Remove legacy cron jobs (cleanup)"
|
||||||
|
cron:
|
||||||
|
name: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
user: "{{ ansible_user | default('root') }}"
|
||||||
|
loop:
|
||||||
|
- "legacy-deadman-watch"
|
||||||
|
- "old-health-check"
|
||||||
|
- "backup-deadman"
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "List active cron jobs"
|
||||||
|
shell: "crontab -l 2>/dev/null | grep -v '^#' | grep -v '^$' || echo 'No cron jobs found.'"
|
||||||
|
register: active_crons
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "Report cron status"
|
||||||
|
debug:
|
||||||
|
msg: |
|
||||||
|
{{ wizard_name }} cron jobs deployed.
|
||||||
|
Active:
|
||||||
|
{{ active_crons.stdout }}
|
||||||
17
ansible/roles/deadman_switch/handlers/main.yml
Normal file
17
ansible/roles/deadman_switch/handlers/main.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
- name: "Enable deadman service"
|
||||||
|
systemd:
|
||||||
|
name: "deadman-{{ wizard_name | lower }}.service"
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: "Enable deadman timer"
|
||||||
|
systemd:
|
||||||
|
name: "deadman-{{ wizard_name | lower }}.timer"
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
- name: "Load deadman plist"
|
||||||
|
shell: "launchctl load {{ ansible_env.HOME }}/Library/LaunchAgents/com.timmy.deadman.{{ wizard_name | lower }}.plist"
|
||||||
|
ignore_errors: true
|
||||||
53
ansible/roles/deadman_switch/tasks/main.yml
Normal file
53
ansible/roles/deadman_switch/tasks/main.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# deadman_switch/tasks — Wire the Deadman Switch ACTION
|
||||||
|
# =============================================================================
|
||||||
|
# The watch fires. This makes it DO something:
|
||||||
|
# - On healthy check: snapshot current config as "last known good"
|
||||||
|
# - On failed check: rollback to last known good, restart agent
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Create snapshot directory"
|
||||||
|
file:
|
||||||
|
path: "{{ deadman_snapshot_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: "Deploy deadman switch script"
|
||||||
|
template:
|
||||||
|
src: deadman_action.sh.j2
|
||||||
|
dest: "{{ wizard_home }}/deadman_action.sh"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: "Deploy deadman systemd service"
|
||||||
|
template:
|
||||||
|
src: deadman_switch.service.j2
|
||||||
|
dest: "/etc/systemd/system/deadman-{{ wizard_name | lower }}.service"
|
||||||
|
mode: "0644"
|
||||||
|
when: machine_type == 'vps'
|
||||||
|
notify: "Enable deadman service"
|
||||||
|
|
||||||
|
- name: "Deploy deadman systemd timer"
|
||||||
|
template:
|
||||||
|
src: deadman_switch.timer.j2
|
||||||
|
dest: "/etc/systemd/system/deadman-{{ wizard_name | lower }}.timer"
|
||||||
|
mode: "0644"
|
||||||
|
when: machine_type == 'vps'
|
||||||
|
notify: "Enable deadman timer"
|
||||||
|
|
||||||
|
- name: "Deploy deadman launchd plist (Mac)"
|
||||||
|
template:
|
||||||
|
src: deadman_switch.plist.j2
|
||||||
|
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/com.timmy.deadman.{{ wizard_name | lower }}.plist"
|
||||||
|
mode: "0644"
|
||||||
|
when: machine_type == 'mac'
|
||||||
|
notify: "Load deadman plist"
|
||||||
|
|
||||||
|
- name: "Take initial config snapshot"
|
||||||
|
copy:
|
||||||
|
src: "{{ wizard_home }}/config.yaml"
|
||||||
|
dest: "{{ deadman_snapshot_dir }}/config.yaml.known_good"
|
||||||
|
remote_src: true
|
||||||
|
mode: "0444"
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
153
ansible/roles/deadman_switch/templates/deadman_action.sh.j2
Normal file
153
ansible/roles/deadman_switch/templates/deadman_action.sh.j2
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Deadman Switch ACTION — {{ wizard_name }}
|
||||||
|
# =============================================================================
|
||||||
|
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
|
||||||
|
# DO NOT EDIT MANUALLY.
|
||||||
|
#
|
||||||
|
# On healthy check: snapshot current config as "last known good"
|
||||||
|
# On failed check: rollback config to last known good, restart agent
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WIZARD_NAME="{{ wizard_name }}"
|
||||||
|
WIZARD_HOME="{{ wizard_home }}"
|
||||||
|
CONFIG_FILE="{{ wizard_home }}/config.yaml"
|
||||||
|
SNAPSHOT_DIR="{{ deadman_snapshot_dir }}"
|
||||||
|
SNAPSHOT_FILE="${SNAPSHOT_DIR}/config.yaml.known_good"
|
||||||
|
REQUEST_LOG_DB="{{ request_log_path }}"
|
||||||
|
LOG_DIR="{{ timmy_log_dir }}"
|
||||||
|
LOG_FILE="${LOG_DIR}/deadman-${WIZARD_NAME}.log"
|
||||||
|
MAX_SNAPSHOTS={{ deadman_max_snapshots }}
|
||||||
|
RESTART_COOLDOWN={{ deadman_restart_cooldown }}
|
||||||
|
MAX_RESTART_ATTEMPTS={{ deadman_max_restart_attempts }}
|
||||||
|
COOLDOWN_FILE="${LOG_DIR}/deadman_cooldown_${WIZARD_NAME}"
|
||||||
|
SERVICE_NAME="hermes-{{ wizard_name | lower }}"
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
mkdir -p "${SNAPSHOT_DIR}" "${LOG_DIR}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [deadman] [${WIZARD_NAME}] $*" >> "${LOG_FILE}"
|
||||||
|
echo "[deadman] [${WIZARD_NAME}] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_telemetry() {
|
||||||
|
local status="$1"
|
||||||
|
local message="$2"
|
||||||
|
if [ -f "${REQUEST_LOG_DB}" ]; then
|
||||||
|
sqlite3 "${REQUEST_LOG_DB}" "INSERT INTO request_log (timestamp, agent_name, provider, model, endpoint, status, error_message) VALUES (datetime('now'), '${WIZARD_NAME}', 'deadman_switch', 'N/A', 'health_check', '${status}', '${message}');" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot_config() {
|
||||||
|
if [ -f "${CONFIG_FILE}" ]; then
|
||||||
|
cp "${CONFIG_FILE}" "${SNAPSHOT_FILE}"
|
||||||
|
# Keep rolling history
|
||||||
|
cp "${CONFIG_FILE}" "${SNAPSHOT_DIR}/config.yaml.$(date +%s)"
|
||||||
|
# Prune old snapshots
|
||||||
|
ls -t "${SNAPSHOT_DIR}"/config.yaml.[0-9]* 2>/dev/null | tail -n +$((MAX_SNAPSHOTS + 1)) | xargs rm -f 2>/dev/null
|
||||||
|
log "Config snapshot saved."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_config() {
|
||||||
|
if [ -f "${SNAPSHOT_FILE}" ]; then
|
||||||
|
log "Rolling back config to last known good..."
|
||||||
|
cp "${SNAPSHOT_FILE}" "${CONFIG_FILE}"
|
||||||
|
log "Config rolled back."
|
||||||
|
log_telemetry "fallback" "Config rolled back to last known good by deadman switch"
|
||||||
|
else
|
||||||
|
log "ERROR: No known good snapshot found. Pulling from upstream..."
|
||||||
|
cd "${WIZARD_HOME}/workspace/timmy-config" 2>/dev/null && \
|
||||||
|
git pull --ff-only origin {{ upstream_branch }} 2>/dev/null && \
|
||||||
|
cp "wizards/{{ wizard_name | lower }}/config.yaml" "${CONFIG_FILE}" && \
|
||||||
|
log "Config restored from upstream." || \
|
||||||
|
log "CRITICAL: Cannot restore config from any source."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_agent() {
|
||||||
|
# Check cooldown
|
||||||
|
if [ -f "${COOLDOWN_FILE}" ]; then
|
||||||
|
local last_restart
|
||||||
|
last_restart=$(cat "${COOLDOWN_FILE}")
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
local elapsed=$((now - last_restart))
|
||||||
|
if [ "${elapsed}" -lt "${RESTART_COOLDOWN}" ]; then
|
||||||
|
log "Restart cooldown active (${elapsed}s / ${RESTART_COOLDOWN}s). Skipping."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Restarting ${SERVICE_NAME}..."
|
||||||
|
date +%s > "${COOLDOWN_FILE}"
|
||||||
|
|
||||||
|
{% if machine_type == 'vps' %}
|
||||||
|
systemctl restart "${SERVICE_NAME}" 2>/dev/null && \
|
||||||
|
log "Agent restarted via systemd." || \
|
||||||
|
log "ERROR: systemd restart failed."
|
||||||
|
{% else %}
|
||||||
|
launchctl kickstart -k "ai.hermes.{{ wizard_name | lower }}" 2>/dev/null && \
|
||||||
|
log "Agent restarted via launchctl." || \
|
||||||
|
(cd "${WIZARD_HOME}" && hermes agent start --daemon 2>/dev/null && \
|
||||||
|
log "Agent restarted via hermes CLI.") || \
|
||||||
|
log "ERROR: All restart methods failed."
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
log_telemetry "success" "Agent restarted by deadman switch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Health Check ---
|
||||||
|
check_health() {
|
||||||
|
# Check 1: Is the agent process running?
|
||||||
|
{% if machine_type == 'vps' %}
|
||||||
|
if ! systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||||
|
if ! pgrep -f "hermes" > /dev/null 2>/dev/null; then
|
||||||
|
log "FAIL: Agent process not running."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
{% else %}
|
||||||
|
if ! pgrep -f "hermes" > /dev/null 2>/dev/null; then
|
||||||
|
log "FAIL: Agent process not running."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
# Check 2: Is the API port responding?
|
||||||
|
if ! timeout 10 bash -c "echo > /dev/tcp/127.0.0.1/{{ api_port }}" 2>/dev/null; then
|
||||||
|
log "FAIL: API port {{ api_port }} not responding."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check 3: Does the config contain banned providers?
|
||||||
|
if grep -qi 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' "${CONFIG_FILE}" 2>/dev/null; then
|
||||||
|
log "FAIL: Config contains banned provider (Anthropic). Rolling back."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
main() {
|
||||||
|
log "Health check starting..."
|
||||||
|
|
||||||
|
if check_health; then
|
||||||
|
log "HEALTHY — snapshotting config."
|
||||||
|
snapshot_config
|
||||||
|
log_telemetry "success" "Health check passed"
|
||||||
|
else
|
||||||
|
log "UNHEALTHY — initiating recovery."
|
||||||
|
log_telemetry "error" "Health check failed — initiating rollback"
|
||||||
|
rollback_config
|
||||||
|
restart_agent
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Health check complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!-- Deadman Switch — {{ wizard_name }}. Generated by Ansible. DO NOT EDIT MANUALLY. -->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.timmy.deadman.{{ wizard_name | lower }}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>{{ wizard_home }}/deadman_action.sh</string>
|
||||||
|
</array>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>{{ deadman_check_interval }}</integer>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Deadman Switch — {{ wizard_name }}
|
||||||
|
# Generated by Ansible. DO NOT EDIT MANUALLY.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Deadman Switch for {{ wizard_name }} wizard
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart={{ wizard_home }}/deadman_action.sh
|
||||||
|
User={{ ansible_user | default('root') }}
|
||||||
|
StandardOutput=append:{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log
|
||||||
|
StandardError=append:{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Deadman Switch Timer — {{ wizard_name }}
|
||||||
|
# Generated by Ansible. DO NOT EDIT MANUALLY.
|
||||||
|
# Runs every {{ deadman_check_interval // 60 }} minutes.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Deadman Switch Timer for {{ wizard_name }} wizard
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=60
|
||||||
|
OnUnitActiveSec={{ deadman_check_interval }}s
|
||||||
|
AccuracySec=30s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
6
ansible/roles/golden_state/defaults/main.yml
Normal file
6
ansible/roles/golden_state/defaults/main.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
# golden_state defaults
|
||||||
|
# The golden_state_providers list is defined in group_vars/wizards.yml
|
||||||
|
# and inventory/hosts.yml (global vars).
|
||||||
|
golden_state_enforce: true
|
||||||
|
golden_state_backup_before_deploy: true
|
||||||
46
ansible/roles/golden_state/tasks/main.yml
Normal file
46
ansible/roles/golden_state/tasks/main.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# golden_state/tasks — Deploy and enforce golden state provider chain
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Backup current config before golden state deploy"
|
||||||
|
copy:
|
||||||
|
src: "{{ wizard_home }}/config.yaml"
|
||||||
|
dest: "{{ wizard_home }}/config.yaml.pre-golden-{{ ansible_date_time.epoch }}"
|
||||||
|
remote_src: true
|
||||||
|
when: golden_state_backup_before_deploy
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Deploy golden state wizard config"
|
||||||
|
template:
|
||||||
|
src: "../../wizard_base/templates/wizard_config.yaml.j2"
|
||||||
|
dest: "{{ wizard_home }}/config.yaml"
|
||||||
|
mode: "0644"
|
||||||
|
backup: true
|
||||||
|
notify:
|
||||||
|
- "Restart hermes agent (systemd)"
|
||||||
|
- "Restart hermes agent (launchctl)"
|
||||||
|
|
||||||
|
- name: "Scan for banned providers in all config files"
|
||||||
|
shell: |
|
||||||
|
FOUND=0
|
||||||
|
for f in {{ wizard_home }}/config.yaml {{ hermes_home }}/config.yaml; do
|
||||||
|
if [ -f "$f" ]; then
|
||||||
|
if grep -qi 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' "$f"; then
|
||||||
|
echo "BANNED PROVIDER in $f:"
|
||||||
|
grep -ni 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' "$f"
|
||||||
|
FOUND=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $FOUND
|
||||||
|
register: provider_scan
|
||||||
|
changed_when: false
|
||||||
|
failed_when: provider_scan.rc != 0 and provider_ban_enforcement == 'strict'
|
||||||
|
|
||||||
|
- name: "Report golden state deployment"
|
||||||
|
debug:
|
||||||
|
msg: >
|
||||||
|
{{ wizard_name }} golden state deployed.
|
||||||
|
Provider chain: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}.
|
||||||
|
Banned provider scan: {{ 'CLEAN' if provider_scan.rc == 0 else 'VIOLATIONS FOUND' }}.
|
||||||
64
ansible/roles/request_log/files/request_log_schema.sql
Normal file
64
ansible/roles/request_log/files/request_log_schema.sql
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- request_log — Inference Telemetry Table
|
||||||
|
-- =============================================================================
|
||||||
|
-- Every agent writes to this table BEFORE and AFTER every inference call.
|
||||||
|
-- No exceptions. No summarizing. No describing what you would log.
|
||||||
|
-- Actually write the row.
|
||||||
|
--
|
||||||
|
-- Source: KT Bezalel Architecture Session 2026-04-08
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS request_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
agent_name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
tokens_in INTEGER,
|
||||||
|
tokens_out INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
status TEXT NOT NULL, -- 'success', 'error', 'timeout', 'fallback'
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for common queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_log_agent
|
||||||
|
ON request_log (agent_name, timestamp);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_log_provider
|
||||||
|
ON request_log (provider, timestamp);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_log_status
|
||||||
|
ON request_log (status, timestamp);
|
||||||
|
|
||||||
|
-- View: recent activity per agent (last hour)
|
||||||
|
CREATE VIEW IF NOT EXISTS v_recent_activity AS
|
||||||
|
SELECT
|
||||||
|
agent_name,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
status,
|
||||||
|
COUNT(*) as call_count,
|
||||||
|
AVG(latency_ms) as avg_latency_ms,
|
||||||
|
SUM(tokens_in) as total_tokens_in,
|
||||||
|
SUM(tokens_out) as total_tokens_out
|
||||||
|
FROM request_log
|
||||||
|
WHERE timestamp > datetime('now', '-1 hour')
|
||||||
|
GROUP BY agent_name, provider, model, status;
|
||||||
|
|
||||||
|
-- View: provider reliability (last 24 hours)
|
||||||
|
CREATE VIEW IF NOT EXISTS v_provider_reliability AS
|
||||||
|
SELECT
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
COUNT(*) as total_calls,
|
||||||
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successes,
|
||||||
|
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors,
|
||||||
|
SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END) as timeouts,
|
||||||
|
SUM(CASE WHEN status = 'fallback' THEN 1 ELSE 0 END) as fallbacks,
|
||||||
|
ROUND(100.0 * SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) / COUNT(*), 1) as success_rate,
|
||||||
|
AVG(latency_ms) as avg_latency_ms
|
||||||
|
FROM request_log
|
||||||
|
WHERE timestamp > datetime('now', '-24 hours')
|
||||||
|
GROUP BY provider, model;
|
||||||
50
ansible/roles/request_log/tasks/main.yml
Normal file
50
ansible/roles/request_log/tasks/main.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# request_log/tasks — Deploy Telemetry Table
|
||||||
|
# =============================================================================
|
||||||
|
# "This is non-negotiable infrastructure. Without it, we cannot verify
|
||||||
|
# if any agent actually executed what it claims."
|
||||||
|
# — KT Bezalel 2026-04-08
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Create telemetry directory"
|
||||||
|
file:
|
||||||
|
path: "{{ request_log_path | dirname }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: "Deploy request_log schema"
|
||||||
|
copy:
|
||||||
|
src: request_log_schema.sql
|
||||||
|
dest: "{{ wizard_home }}/request_log_schema.sql"
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: "Initialize request_log database"
|
||||||
|
shell: |
|
||||||
|
sqlite3 "{{ request_log_path }}" < "{{ wizard_home }}/request_log_schema.sql"
|
||||||
|
args:
|
||||||
|
creates: "{{ request_log_path }}"
|
||||||
|
|
||||||
|
- name: "Verify request_log table exists"
|
||||||
|
shell: |
|
||||||
|
sqlite3 "{{ request_log_path }}" ".tables" | grep -q "request_log"
|
||||||
|
register: table_check
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "Verify request_log schema matches"
|
||||||
|
shell: |
|
||||||
|
sqlite3 "{{ request_log_path }}" ".schema request_log" | grep -q "agent_name"
|
||||||
|
register: schema_check
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "Set permissions on request_log database"
|
||||||
|
file:
|
||||||
|
path: "{{ request_log_path }}"
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: "Report request_log status"
|
||||||
|
debug:
|
||||||
|
msg: >
|
||||||
|
{{ wizard_name }} request_log: {{ request_log_path }}
|
||||||
|
— table exists: {{ table_check.rc == 0 }}
|
||||||
|
— schema valid: {{ schema_check.rc == 0 }}
|
||||||
6
ansible/roles/wizard_base/defaults/main.yml
Normal file
6
ansible/roles/wizard_base/defaults/main.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
# wizard_base defaults
|
||||||
|
wizard_user: "{{ ansible_user | default('root') }}"
|
||||||
|
wizard_group: "{{ ansible_user | default('root') }}"
|
||||||
|
timmy_base_dir: "~/.local/timmy"
|
||||||
|
timmy_config_repo: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||||
11
ansible/roles/wizard_base/handlers/main.yml
Normal file
11
ansible/roles/wizard_base/handlers/main.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
- name: "Restart hermes agent (systemd)"
|
||||||
|
systemd:
|
||||||
|
name: "hermes-{{ wizard_name | lower }}"
|
||||||
|
state: restarted
|
||||||
|
when: machine_type == 'vps'
|
||||||
|
|
||||||
|
- name: "Restart hermes agent (launchctl)"
|
||||||
|
shell: "launchctl kickstart -k ai.hermes.{{ wizard_name | lower }}"
|
||||||
|
when: machine_type == 'mac'
|
||||||
|
ignore_errors: true
|
||||||
69
ansible/roles/wizard_base/tasks/main.yml
Normal file
69
ansible/roles/wizard_base/tasks/main.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# wizard_base/tasks — Common wizard setup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
- name: "Create wizard directories"
|
||||||
|
file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ wizard_home }}"
|
||||||
|
- "{{ wizard_home }}/workspace"
|
||||||
|
- "{{ hermes_home }}"
|
||||||
|
- "{{ hermes_home }}/bin"
|
||||||
|
- "{{ hermes_home }}/skins"
|
||||||
|
- "{{ hermes_home }}/playbooks"
|
||||||
|
- "{{ hermes_home }}/memories"
|
||||||
|
- "~/.local/timmy"
|
||||||
|
- "~/.local/timmy/fleet-health"
|
||||||
|
- "~/.local/timmy/snapshots"
|
||||||
|
- "~/.timmy"
|
||||||
|
|
||||||
|
- name: "Clone/update timmy-config"
|
||||||
|
git:
|
||||||
|
repo: "{{ upstream_repo }}"
|
||||||
|
dest: "{{ wizard_home }}/workspace/timmy-config"
|
||||||
|
version: "{{ upstream_branch }}"
|
||||||
|
force: false
|
||||||
|
update: true
|
||||||
|
ignore_errors: true # May fail on first run if no SSH key
|
||||||
|
|
||||||
|
- name: "Deploy SOUL.md"
|
||||||
|
copy:
|
||||||
|
src: "{{ wizard_home }}/workspace/timmy-config/SOUL.md"
|
||||||
|
dest: "~/.timmy/SOUL.md"
|
||||||
|
remote_src: true
|
||||||
|
mode: "0644"
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Deploy thin config (immutable pointer to upstream)"
|
||||||
|
template:
|
||||||
|
src: thin_config.yml.j2
|
||||||
|
dest: "{{ thin_config_path }}"
|
||||||
|
mode: "{{ thin_config_mode }}"
|
||||||
|
tags: [thin_config]
|
||||||
|
|
||||||
|
- name: "Ensure Python3 and pip are available"
|
||||||
|
package:
|
||||||
|
name:
|
||||||
|
- python3
|
||||||
|
- python3-pip
|
||||||
|
state: present
|
||||||
|
when: machine_type == 'vps'
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Ensure PyYAML is installed (for config validation)"
|
||||||
|
pip:
|
||||||
|
name: pyyaml
|
||||||
|
state: present
|
||||||
|
when: machine_type == 'vps'
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: "Create Ansible log directory"
|
||||||
|
file:
|
||||||
|
path: /var/log/ansible
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
ignore_errors: true
|
||||||
41
ansible/roles/wizard_base/templates/thin_config.yml.j2
Normal file
41
ansible/roles/wizard_base/templates/thin_config.yml.j2
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Thin Config — {{ wizard_name }}
|
||||||
|
# =============================================================================
|
||||||
|
# THIS FILE IS READ-ONLY. Agents CANNOT modify it.
|
||||||
|
# It contains only pointers to upstream. The actual config lives in Gitea.
|
||||||
|
#
|
||||||
|
# Agent wakes up → pulls config from upstream → loads → runs.
|
||||||
|
# If anything tries to mutate this → fails gracefully → pulls fresh on restart.
|
||||||
|
#
|
||||||
|
# Only way to permanently change config: commit to Gitea, merge PR, Ansible deploys.
|
||||||
|
#
|
||||||
|
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
|
||||||
|
# DO NOT EDIT MANUALLY.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
identity:
|
||||||
|
wizard_name: "{{ wizard_name }}"
|
||||||
|
wizard_role: "{{ wizard_role }}"
|
||||||
|
machine: "{{ inventory_hostname }}"
|
||||||
|
|
||||||
|
upstream:
|
||||||
|
repo: "{{ upstream_repo }}"
|
||||||
|
branch: "{{ upstream_branch }}"
|
||||||
|
config_path: "wizards/{{ wizard_name | lower }}/config.yaml"
|
||||||
|
pull_on_wake: {{ config_pull_on_wake | lower }}
|
||||||
|
|
||||||
|
recovery:
|
||||||
|
deadman_enabled: {{ deadman_enabled | lower }}
|
||||||
|
snapshot_dir: "{{ deadman_snapshot_dir }}"
|
||||||
|
restart_cooldown: {{ deadman_restart_cooldown }}
|
||||||
|
max_restart_attempts: {{ deadman_max_restart_attempts }}
|
||||||
|
escalation_channel: "{{ deadman_escalation_channel }}"
|
||||||
|
|
||||||
|
telemetry:
|
||||||
|
request_log_path: "{{ request_log_path }}"
|
||||||
|
request_log_enabled: {{ request_log_enabled | lower }}
|
||||||
|
|
||||||
|
local_overrides:
|
||||||
|
# Runtime overrides go here. They are EPHEMERAL — not persisted across restarts.
|
||||||
|
# On restart, this section is reset to empty.
|
||||||
|
{}
|
||||||
115
ansible/roles/wizard_base/templates/wizard_config.yaml.j2
Normal file
115
ansible/roles/wizard_base/templates/wizard_config.yaml.j2
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# {{ wizard_name }} — Wizard Configuration (Golden State)
|
||||||
|
# =============================================================================
|
||||||
|
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
|
||||||
|
# DO NOT EDIT MANUALLY. Changes go through Gitea PR → Ansible deploy.
|
||||||
|
#
|
||||||
|
# Provider chain: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}
|
||||||
|
# Anthropic is PERMANENTLY BANNED.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
model:
|
||||||
|
default: {{ wizard_model_primary }}
|
||||||
|
provider: {{ wizard_provider_primary }}
|
||||||
|
context_length: 65536
|
||||||
|
base_url: {{ golden_state_providers[0].base_url }}
|
||||||
|
|
||||||
|
toolsets:
|
||||||
|
- all
|
||||||
|
|
||||||
|
fallback_providers:
|
||||||
|
{% for provider in golden_state_providers %}
|
||||||
|
- provider: {{ provider.name }}
|
||||||
|
model: {{ provider.model }}
|
||||||
|
{% if provider.base_url is defined %}
|
||||||
|
base_url: {{ provider.base_url }}
|
||||||
|
{% endif %}
|
||||||
|
{% if provider.api_key_env is defined %}
|
||||||
|
api_key_env: {{ provider.api_key_env }}
|
||||||
|
{% endif %}
|
||||||
|
timeout: {{ provider.timeout }}
|
||||||
|
reason: "{{ provider.reason }}"
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
agent:
|
||||||
|
max_turns: {{ agent_max_turns }}
|
||||||
|
reasoning_effort: {{ agent_reasoning_effort }}
|
||||||
|
verbose: {{ agent_verbose | lower }}
|
||||||
|
|
||||||
|
terminal:
|
||||||
|
backend: local
|
||||||
|
cwd: .
|
||||||
|
timeout: 180
|
||||||
|
persistent_shell: true
|
||||||
|
|
||||||
|
browser:
|
||||||
|
inactivity_timeout: 120
|
||||||
|
command_timeout: 30
|
||||||
|
record_sessions: false
|
||||||
|
|
||||||
|
display:
|
||||||
|
compact: false
|
||||||
|
personality: ''
|
||||||
|
resume_display: full
|
||||||
|
busy_input_mode: interrupt
|
||||||
|
bell_on_complete: false
|
||||||
|
show_reasoning: false
|
||||||
|
streaming: false
|
||||||
|
show_cost: false
|
||||||
|
tool_progress: all
|
||||||
|
|
||||||
|
memory:
|
||||||
|
memory_enabled: true
|
||||||
|
user_profile_enabled: true
|
||||||
|
memory_char_limit: 2200
|
||||||
|
user_char_limit: 1375
|
||||||
|
nudge_interval: 10
|
||||||
|
flush_min_turns: 6
|
||||||
|
|
||||||
|
approvals:
|
||||||
|
mode: {{ agent_approval_mode }}
|
||||||
|
|
||||||
|
security:
|
||||||
|
redact_secrets: true
|
||||||
|
tirith_enabled: false
|
||||||
|
|
||||||
|
platforms:
|
||||||
|
api_server:
|
||||||
|
enabled: true
|
||||||
|
extra:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: {{ api_port }}
|
||||||
|
|
||||||
|
session_reset:
|
||||||
|
mode: none
|
||||||
|
idle_minutes: 0
|
||||||
|
|
||||||
|
skills:
|
||||||
|
creation_nudge_interval: 15
|
||||||
|
|
||||||
|
system_prompt_suffix: |
|
||||||
|
You are {{ wizard_name }}, {{ wizard_role }}.
|
||||||
|
Your soul is defined in SOUL.md — read it, live it.
|
||||||
|
Hermes is your harness.
|
||||||
|
{{ golden_state_providers[0].name }} is your primary provider.
|
||||||
|
Refusal over fabrication. If you do not know, say so.
|
||||||
|
Sovereignty and service always.
|
||||||
|
|
||||||
|
providers:
|
||||||
|
{% for provider in golden_state_providers %}
|
||||||
|
{{ provider.name }}:
|
||||||
|
base_url: {{ provider.base_url }}
|
||||||
|
timeout: {{ provider.timeout | default(60) }}
|
||||||
|
{% if provider.name == 'kimi-coding' %}
|
||||||
|
max_retries: 3
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BANNED PROVIDERS — DO NOT ADD
|
||||||
|
# =============================================================================
|
||||||
|
# The following providers are PERMANENTLY BANNED:
|
||||||
|
# - anthropic (any model: claude-sonnet, claude-opus, claude-haiku)
|
||||||
|
# Enforcement: pre-commit hook, linter, Ansible validation, this comment.
|
||||||
|
# Adding any banned provider will cause Ansible deployment to FAIL.
|
||||||
|
# =============================================================================
|
||||||
75
ansible/scripts/deploy_on_webhook.sh
Normal file
75
ansible/scripts/deploy_on_webhook.sh
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Gitea Webhook Handler — Trigger Ansible Deploy on Merge
|
||||||
|
# =============================================================================
|
||||||
|
# This script is called by the Gitea webhook when a PR is merged
|
||||||
|
# to the main branch of timmy-config.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Add webhook in Gitea: Settings → Webhooks → Add Webhook
|
||||||
|
# 2. URL: http://localhost:9000/hooks/deploy-timmy-config
|
||||||
|
# 3. Events: Pull Request (merged only)
|
||||||
|
# 4. Secret: <configured in Gitea>
|
||||||
|
#
|
||||||
|
# This script runs ansible-pull to update the local machine.
|
||||||
|
# For fleet-wide deploys, each machine runs ansible-pull independently.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||||
|
BRANCH="main"
|
||||||
|
ANSIBLE_DIR="ansible"
|
||||||
|
LOG_FILE="/var/log/ansible/webhook-deploy.log"
|
||||||
|
LOCK_FILE="/tmp/ansible-deploy.lock"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [webhook] $*" | tee -a "${LOG_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prevent concurrent deploys
|
||||||
|
if [ -f "${LOCK_FILE}" ]; then
|
||||||
|
LOCK_AGE=$(( $(date +%s) - $(stat -c %Y "${LOCK_FILE}" 2>/dev/null || echo 0) ))
|
||||||
|
if [ "${LOCK_AGE}" -lt 300 ]; then
|
||||||
|
log "Deploy already in progress (lock age: ${LOCK_AGE}s). Skipping."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log "Stale lock file (${LOCK_AGE}s old). Removing."
|
||||||
|
rm -f "${LOCK_FILE}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap 'rm -f "${LOCK_FILE}"' EXIT
|
||||||
|
touch "${LOCK_FILE}"
|
||||||
|
|
||||||
|
log "Webhook triggered. Starting ansible-pull..."
|
||||||
|
|
||||||
|
# Pull latest config
|
||||||
|
cd /tmp
|
||||||
|
rm -rf timmy-config-deploy
|
||||||
|
git clone --depth 1 --branch "${BRANCH}" "${REPO}" timmy-config-deploy 2>&1 | tee -a "${LOG_FILE}"
|
||||||
|
|
||||||
|
cd timmy-config-deploy/${ANSIBLE_DIR}
|
||||||
|
|
||||||
|
# Run Ansible against localhost
|
||||||
|
log "Running Ansible playbook..."
|
||||||
|
ansible-playbook \
|
||||||
|
-i inventory/hosts.yml \
|
||||||
|
playbooks/site.yml \
|
||||||
|
--limit "$(hostname)" \
|
||||||
|
--diff \
|
||||||
|
2>&1 | tee -a "${LOG_FILE}"
|
||||||
|
|
||||||
|
RESULT=$?
|
||||||
|
|
||||||
|
if [ ${RESULT} -eq 0 ]; then
|
||||||
|
log "Deploy successful."
|
||||||
|
else
|
||||||
|
log "ERROR: Deploy failed with exit code ${RESULT}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -rf /tmp/timmy-config-deploy
|
||||||
|
|
||||||
|
log "Webhook handler complete."
|
||||||
|
exit ${RESULT}
|
||||||
155
ansible/scripts/validate_config.py
Normal file
155
ansible/scripts/validate_config.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Config Validator — The Timmy Foundation
|
||||||
|
Validates wizard configs against golden state rules.
|
||||||
|
Run before any config deploy to catch violations early.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 validate_config.py <config_file>
|
||||||
|
python3 validate_config.py --all # Validate all wizard configs
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 — All validations passed
|
||||||
|
1 — Validation errors found
|
||||||
|
2 — File not found or parse error
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import fnmatch
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# === BANNED PROVIDERS — HARD POLICY ===
|
||||||
|
BANNED_PROVIDERS = {"anthropic", "claude"}
|
||||||
|
BANNED_MODEL_PATTERNS = [
|
||||||
|
"claude-*",
|
||||||
|
"anthropic/*",
|
||||||
|
"*sonnet*",
|
||||||
|
"*opus*",
|
||||||
|
"*haiku*",
|
||||||
|
]
|
||||||
|
|
||||||
|
# === REQUIRED FIELDS ===
|
||||||
|
REQUIRED_FIELDS = {
|
||||||
|
"model": ["default", "provider"],
|
||||||
|
"fallback_providers": None, # Must exist as a list
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_banned_model(model_name: str) -> bool:
|
||||||
|
"""Check if a model name matches any banned pattern."""
|
||||||
|
model_lower = model_name.lower()
|
||||||
|
for pattern in BANNED_MODEL_PATTERNS:
|
||||||
|
if fnmatch.fnmatch(model_lower, pattern):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config_path: str) -> list[str]:
|
||||||
|
"""Validate a wizard config file. Returns list of error strings."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return [f"File not found: {config_path}"]
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
return [f"YAML parse error: {e}"]
|
||||||
|
|
||||||
|
if not cfg:
|
||||||
|
return ["Config file is empty"]
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
for section, fields in REQUIRED_FIELDS.items():
|
||||||
|
if section not in cfg:
|
||||||
|
errors.append(f"Missing required section: {section}")
|
||||||
|
elif fields:
|
||||||
|
for field in fields:
|
||||||
|
if field not in cfg[section]:
|
||||||
|
errors.append(f"Missing required field: {section}.{field}")
|
||||||
|
|
||||||
|
# Check default provider
|
||||||
|
default_provider = cfg.get("model", {}).get("provider", "")
|
||||||
|
if default_provider.lower() in BANNED_PROVIDERS:
|
||||||
|
errors.append(f"BANNED default provider: {default_provider}")
|
||||||
|
|
||||||
|
default_model = cfg.get("model", {}).get("default", "")
|
||||||
|
if is_banned_model(default_model):
|
||||||
|
errors.append(f"BANNED default model: {default_model}")
|
||||||
|
|
||||||
|
# Check fallback providers
|
||||||
|
for i, fb in enumerate(cfg.get("fallback_providers", [])):
|
||||||
|
provider = fb.get("provider", "")
|
||||||
|
model = fb.get("model", "")
|
||||||
|
|
||||||
|
if provider.lower() in BANNED_PROVIDERS:
|
||||||
|
errors.append(f"BANNED fallback provider [{i}]: {provider}")
|
||||||
|
|
||||||
|
if is_banned_model(model):
|
||||||
|
errors.append(f"BANNED fallback model [{i}]: {model}")
|
||||||
|
|
||||||
|
# Check providers section
|
||||||
|
for name, provider_cfg in cfg.get("providers", {}).items():
|
||||||
|
if name.lower() in BANNED_PROVIDERS:
|
||||||
|
errors.append(f"BANNED provider in providers section: {name}")
|
||||||
|
|
||||||
|
base_url = str(provider_cfg.get("base_url", ""))
|
||||||
|
if "anthropic" in base_url.lower():
|
||||||
|
errors.append(f"BANNED URL in provider {name}: {base_url}")
|
||||||
|
|
||||||
|
# Check system prompt for banned references
|
||||||
|
prompt = cfg.get("system_prompt_suffix", "")
|
||||||
|
if isinstance(prompt, str):
|
||||||
|
for banned in BANNED_PROVIDERS:
|
||||||
|
if banned in prompt.lower():
|
||||||
|
errors.append(f"BANNED provider referenced in system_prompt_suffix: {banned}")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <config_file> [--all]")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if sys.argv[1] == "--all":
|
||||||
|
# Validate all wizard configs in the repo
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
wizard_dir = repo_root / "wizards"
|
||||||
|
all_errors = {}
|
||||||
|
|
||||||
|
for wizard_path in sorted(wizard_dir.iterdir()):
|
||||||
|
config_file = wizard_path / "config.yaml"
|
||||||
|
if config_file.exists():
|
||||||
|
errors = validate_config(str(config_file))
|
||||||
|
if errors:
|
||||||
|
all_errors[wizard_path.name] = errors
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
print("VALIDATION FAILED:")
|
||||||
|
for wizard, errors in all_errors.items():
|
||||||
|
print(f"\n {wizard}:")
|
||||||
|
for err in errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("All wizard configs passed validation.")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
config_path = sys.argv[1]
|
||||||
|
errors = validate_config(config_path)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"VALIDATION FAILED for {config_path}:")
|
||||||
|
for err in errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"PASSED: {config_path}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -202,6 +202,19 @@ curl -s -X POST "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}/comments" \\
|
|||||||
REVIEW CHECKLIST BEFORE YOU PUSH:
|
REVIEW CHECKLIST BEFORE YOU PUSH:
|
||||||
{review}
|
{review}
|
||||||
|
|
||||||
|
COMMIT DISCIPLINE (CRITICAL):
|
||||||
|
- Commit every 3-5 tool calls. Do NOT wait until the end.
|
||||||
|
- After every meaningful file change: git add -A && git commit -m "WIP: <what changed>"
|
||||||
|
- Before running any destructive command: commit current state first.
|
||||||
|
- If you are unsure whether to commit: commit. WIP commits are safe. Lost work is not.
|
||||||
|
- Never use --no-verify.
|
||||||
|
- The auto-commit-guard is your safety net, but do not rely on it. Commit proactively.
|
||||||
|
|
||||||
|
RECOVERY COMMANDS (if interrupted, another agent can resume):
|
||||||
|
git log --oneline -10 # see your WIP commits
|
||||||
|
git diff HEAD~1 # see what the last commit changed
|
||||||
|
git status # see uncommitted work
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Do not skip hooks with --no-verify.
|
- Do not skip hooks with --no-verify.
|
||||||
- Do not silently widen the scope.
|
- Do not silently widen the scope.
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ run_worker() {
|
|||||||
CYCLE_END=$(date +%s)
|
CYCLE_END=$(date +%s)
|
||||||
CYCLE_DURATION=$((CYCLE_END - CYCLE_START))
|
CYCLE_DURATION=$((CYCLE_END - CYCLE_START))
|
||||||
|
|
||||||
|
# --- Mid-session auto-commit: commit before timeout if work is dirty ---
|
||||||
|
cd "$worktree" 2>/dev/null || true
|
||||||
|
# Ensure auto-commit-guard is running
|
||||||
|
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||||||
|
log "Starting auto-commit-guard daemon"
|
||||||
|
nohup bash "$(dirname "$0")/auto-commit-guard.sh" 120 "$WORKTREE_BASE" >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
# Salvage
|
# Salvage
|
||||||
cd "$worktree" 2>/dev/null || true
|
cd "$worktree" 2>/dev/null || true
|
||||||
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
|||||||
159
bin/auto-commit-guard.sh
Normal file
159
bin/auto-commit-guard.sh
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# auto-commit-guard.sh — Background daemon that auto-commits uncommitted work
|
||||||
|
#
|
||||||
|
# Usage: auto-commit-guard.sh [interval_seconds] [worktree_base]
|
||||||
|
# auto-commit-guard.sh # defaults: 120s, ~/worktrees
|
||||||
|
# auto-commit-guard.sh 60 # check every 60s
|
||||||
|
# auto-commit-guard.sh 180 ~/my-worktrees
|
||||||
|
#
|
||||||
|
# Scans all git repos under the worktree base for uncommitted changes.
|
||||||
|
# If dirty for >= 1 check cycle, auto-commits with a WIP message.
|
||||||
|
# Pushes unpushed commits so work is always recoverable from the remote.
|
||||||
|
#
|
||||||
|
# Also scans /tmp for orphaned agent workdirs on startup.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
INTERVAL="${1:-120}"
|
||||||
|
WORKTREE_BASE="${2:-$HOME/worktrees}"
|
||||||
|
LOG_DIR="$HOME/.hermes/logs"
|
||||||
|
LOG="$LOG_DIR/auto-commit-guard.log"
|
||||||
|
PIDFILE="$LOG_DIR/auto-commit-guard.pid"
|
||||||
|
ORPHAN_SCAN_DONE="$LOG_DIR/.orphan-scan-done"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
# Single instance guard
|
||||||
|
if [ -f "$PIDFILE" ]; then
|
||||||
|
old_pid=$(cat "$PIDFILE")
|
||||||
|
if kill -0 "$old_pid" 2>/dev/null; then
|
||||||
|
echo "auto-commit-guard already running (PID $old_pid)" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo $$ > "$PIDFILE"
|
||||||
|
trap 'rm -f "$PIDFILE"' EXIT
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] AUTO-COMMIT: $*" >> "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Orphaned workdir scan (runs once on startup) ---
|
||||||
|
scan_orphans() {
|
||||||
|
if [ -f "$ORPHAN_SCAN_DONE" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "Scanning /tmp for orphaned agent workdirs..."
|
||||||
|
local found=0
|
||||||
|
local rescued=0
|
||||||
|
|
||||||
|
for dir in /tmp/*-work-* /tmp/timmy-burn-* /tmp/tc-burn; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
[ -d "$dir/.git" ] || continue
|
||||||
|
|
||||||
|
found=$((found + 1))
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
local dirty
|
||||||
|
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
if [ "${dirty:-0}" -gt 0 ]; then
|
||||||
|
local branch
|
||||||
|
branch=$(git branch --show-current 2>/dev/null || echo "orphan")
|
||||||
|
git add -A 2>/dev/null
|
||||||
|
if git commit -m "WIP: orphan rescue — $dirty file(s) auto-committed on $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
Orphaned workdir detected at $dir.
|
||||||
|
Branch: $branch
|
||||||
|
Rescued by auto-commit-guard on startup." 2>/dev/null; then
|
||||||
|
rescued=$((rescued + 1))
|
||||||
|
log "RESCUED: $dir ($dirty files on branch $branch)"
|
||||||
|
|
||||||
|
# Try to push if remote exists
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch" 2>/dev/null && log "PUSHED orphan rescue: $dir → $branch" || log "PUSH FAILED orphan rescue: $dir (no remote access)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Orphan scan complete: $found workdirs checked, $rescued rescued"
|
||||||
|
touch "$ORPHAN_SCAN_DONE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main guard loop ---
|
||||||
|
guard_cycle() {
|
||||||
|
local committed=0
|
||||||
|
local scanned=0
|
||||||
|
|
||||||
|
# Scan worktree base
|
||||||
|
if [ -d "$WORKTREE_BASE" ]; then
|
||||||
|
for dir in "$WORKTREE_BASE"/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
[ -d "$dir/.git" ] || continue
|
||||||
|
|
||||||
|
scanned=$((scanned + 1))
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
local dirty
|
||||||
|
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
[ "${dirty:-0}" -eq 0 ] && continue
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(git branch --show-current 2>/dev/null || echo "detached")
|
||||||
|
|
||||||
|
git add -A 2>/dev/null
|
||||||
|
if git commit -m "WIP: auto-commit — $dirty file(s) on $branch
|
||||||
|
|
||||||
|
Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ).
|
||||||
|
Work preserved to prevent loss on crash." 2>/dev/null; then
|
||||||
|
committed=$((committed + 1))
|
||||||
|
log "COMMITTED: $dir ($dirty files, branch $branch)"
|
||||||
|
|
||||||
|
# Push to preserve remotely
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also scan /tmp for agent workdirs
|
||||||
|
for dir in /tmp/*-work-*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
[ -d "$dir/.git" ] || continue
|
||||||
|
|
||||||
|
scanned=$((scanned + 1))
|
||||||
|
cd "$dir" 2>/dev/null || continue
|
||||||
|
|
||||||
|
local dirty
|
||||||
|
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
[ "${dirty:-0}" -eq 0 ] && continue
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(git branch --show-current 2>/dev/null || echo "detached")
|
||||||
|
|
||||||
|
git add -A 2>/dev/null
|
||||||
|
if git commit -m "WIP: auto-commit — $dirty file(s) on $branch
|
||||||
|
|
||||||
|
Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ).
|
||||||
|
Agent workdir preserved to prevent loss." 2>/dev/null; then
|
||||||
|
committed=$((committed + 1))
|
||||||
|
log "COMMITTED: $dir ($dirty files, branch $branch)"
|
||||||
|
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$committed" -gt 0 ] && log "Cycle done: $scanned scanned, $committed committed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Entry point ---
|
||||||
|
log "Starting auto-commit-guard (interval=${INTERVAL}s, worktree=${WORKTREE_BASE})"
|
||||||
|
scan_orphans
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
guard_cycle
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
82
bin/banned_provider_scan.py
Normal file
82
bin/banned_provider_scan.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Anthropic Ban Enforcement Scanner.
|
||||||
|
|
||||||
|
Scans all config files, scripts, and playbooks for any references to
|
||||||
|
banned Anthropic providers, models, or API keys.
|
||||||
|
|
||||||
|
Policy: Anthropic is permanently banned (2026-04-09).
|
||||||
|
Refs: ansible/BANNED_PROVIDERS.yml
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BANNED_PATTERNS = [
|
||||||
|
r"anthropic",
|
||||||
|
r"claude-sonnet",
|
||||||
|
r"claude-opus",
|
||||||
|
r"claude-haiku",
|
||||||
|
r"claude-\d",
|
||||||
|
r"api\.anthropic\.com",
|
||||||
|
r"ANTHROPIC_API_KEY",
|
||||||
|
r"CLAUDE_API_KEY",
|
||||||
|
r"sk-ant-",
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWLIST_FILES = {
|
||||||
|
"ansible/BANNED_PROVIDERS.yml", # The ban list itself
|
||||||
|
"bin/banned_provider_scan.py", # This scanner
|
||||||
|
"DEPRECATED.md", # Historical references
|
||||||
|
}
|
||||||
|
|
||||||
|
SCAN_EXTENSIONS = {".py", ".yml", ".yaml", ".json", ".sh", ".toml", ".cfg", ".md"}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file(filepath: str) -> list[tuple[int, str, str]]:
|
||||||
|
"""Return list of (line_num, pattern_matched, line_text) violations."""
|
||||||
|
violations = []
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", errors="replace") as f:
|
||||||
|
for i, line in enumerate(f, 1):
|
||||||
|
for pattern in BANNED_PATTERNS:
|
||||||
|
if re.search(pattern, line, re.IGNORECASE):
|
||||||
|
violations.append((i, pattern, line.strip()))
|
||||||
|
break
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(os.environ.get("SCAN_ROOT", "."))
|
||||||
|
total_violations = 0
|
||||||
|
scanned = 0
|
||||||
|
|
||||||
|
for ext in SCAN_EXTENSIONS:
|
||||||
|
for filepath in root.rglob(f"*{ext}"):
|
||||||
|
rel = str(filepath.relative_to(root))
|
||||||
|
if rel in ALLOWLIST_FILES:
|
||||||
|
continue
|
||||||
|
if ".git" in filepath.parts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
violations = scan_file(str(filepath))
|
||||||
|
scanned += 1
|
||||||
|
if violations:
|
||||||
|
total_violations += len(violations)
|
||||||
|
for line_num, pattern, text in violations:
|
||||||
|
print(f"VIOLATION: {rel}:{line_num} [{pattern}] {text[:120]}")
|
||||||
|
|
||||||
|
print(f"\nScanned {scanned} files. Found {total_violations} violations.")
|
||||||
|
|
||||||
|
if total_violations > 0:
|
||||||
|
print("\n❌ BANNED PROVIDER REFERENCES DETECTED. Fix before merging.")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("\n✓ No banned provider references found.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
120
bin/conflict_detector.py
Normal file
120
bin/conflict_detector.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Merge Conflict Detector — catches sibling PRs that will conflict.
|
||||||
|
|
||||||
|
When multiple PRs branch from the same base commit and touch the same files,
|
||||||
|
merging one invalidates the others. This script detects that pattern
|
||||||
|
before it creates a rebase cascade.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 conflict_detector.py # Check all repos
|
||||||
|
python3 conflict_detector.py --repo OWNER/REPO # Check one repo
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
GITEA_URL — Gitea instance URL
|
||||||
|
GITEA_TOKEN — API token
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||||
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
|
|
||||||
|
REPOS = [
|
||||||
|
"Timmy_Foundation/the-nexus",
|
||||||
|
"Timmy_Foundation/timmy-config",
|
||||||
|
"Timmy_Foundation/timmy-home",
|
||||||
|
"Timmy_Foundation/fleet-ops",
|
||||||
|
"Timmy_Foundation/hermes-agent",
|
||||||
|
"Timmy_Foundation/the-beacon",
|
||||||
|
]
|
||||||
|
|
||||||
|
def api(path):
|
||||||
|
url = f"{GITEA_URL}/api/v1{path}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
if GITEA_TOKEN:
|
||||||
|
req.add_header("Authorization", f"token {GITEA_TOKEN}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def check_repo(repo):
|
||||||
|
"""Find sibling PRs that touch the same files."""
|
||||||
|
prs = api(f"/repos/{repo}/pulls?state=open&limit=50")
|
||||||
|
if not prs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Group PRs by base commit
|
||||||
|
by_base = defaultdict(list)
|
||||||
|
for pr in prs:
|
||||||
|
base_sha = pr.get("merge_base", pr.get("base", {}).get("sha", "unknown"))
|
||||||
|
by_base[base_sha].append(pr)
|
||||||
|
|
||||||
|
conflicts = []
|
||||||
|
|
||||||
|
for base_sha, siblings in by_base.items():
|
||||||
|
if len(siblings) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get files for each sibling
|
||||||
|
file_map = {}
|
||||||
|
for pr in siblings:
|
||||||
|
files = api(f"/repos/{repo}/pulls/{pr['number']}/files")
|
||||||
|
if files:
|
||||||
|
file_map[pr['number']] = set(f['filename'] for f in files)
|
||||||
|
|
||||||
|
# Find overlapping file sets
|
||||||
|
pr_nums = list(file_map.keys())
|
||||||
|
for i in range(len(pr_nums)):
|
||||||
|
for j in range(i+1, len(pr_nums)):
|
||||||
|
a, b = pr_nums[i], pr_nums[j]
|
||||||
|
overlap = file_map[a] & file_map[b]
|
||||||
|
if overlap:
|
||||||
|
conflicts.append({
|
||||||
|
"repo": repo,
|
||||||
|
"pr_a": a,
|
||||||
|
"pr_b": b,
|
||||||
|
"base": base_sha[:8],
|
||||||
|
"files": sorted(overlap),
|
||||||
|
"title_a": next(p["title"] for p in siblings if p["number"] == a),
|
||||||
|
"title_b": next(p["title"] for p in siblings if p["number"] == b),
|
||||||
|
})
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
def main():
|
||||||
|
repos = REPOS
|
||||||
|
if "--repo" in sys.argv:
|
||||||
|
idx = sys.argv.index("--repo") + 1
|
||||||
|
if idx < len(sys.argv):
|
||||||
|
repos = [sys.argv[idx]]
|
||||||
|
|
||||||
|
all_conflicts = []
|
||||||
|
for repo in repos:
|
||||||
|
conflicts = check_repo(repo)
|
||||||
|
all_conflicts.extend(conflicts)
|
||||||
|
|
||||||
|
if not all_conflicts:
|
||||||
|
print("No sibling PR conflicts detected. Queue is clean.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Found {len(all_conflicts)} potential merge conflicts:")
|
||||||
|
print()
|
||||||
|
for c in all_conflicts:
|
||||||
|
print(f" {c['repo']}:")
|
||||||
|
print(f" PR #{c['pr_a']} vs #{c['pr_b']} (base: {c['base']})")
|
||||||
|
print(f" #{c['pr_a']}: {c['title_a'][:60]}")
|
||||||
|
print(f" #{c['pr_b']}: {c['title_b'][:60]}")
|
||||||
|
print(f" Overlapping files: {', '.join(c['files'])}")
|
||||||
|
print(f" → Merge one first, then rebase the other.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
263
bin/deadman-fallback.py
Normal file
263
bin/deadman-fallback.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Dead Man Switch Fallback Engine
|
||||||
|
|
||||||
|
When the dead man switch triggers (zero commits for 2+ hours, model down,
|
||||||
|
Gitea unreachable, etc.), this script diagnoses the failure and applies
|
||||||
|
common sense fallbacks automatically.
|
||||||
|
|
||||||
|
Fallback chain:
|
||||||
|
1. Primary model (Kimi) down -> switch config to local-llama.cpp
|
||||||
|
2. Gitea unreachable -> cache issues locally, retry on recovery
|
||||||
|
3. VPS agents down -> alert + lazarus protocol
|
||||||
|
4. Local llama.cpp down -> try Ollama, then alert-only mode
|
||||||
|
5. All inference dead -> safe mode (cron pauses, alert Alexander)
|
||||||
|
|
||||||
|
Each fallback is reversible. Recovery auto-restores the previous config.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import yaml
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||||
|
CONFIG_PATH = HERMES_HOME / "config.yaml"
|
||||||
|
FALLBACK_STATE = HERMES_HOME / "deadman-fallback-state.json"
|
||||||
|
BACKUP_CONFIG = HERMES_HOME / "config.yaml.pre-fallback"
|
||||||
|
FORGE_URL = "https://forge.alexanderwhitestone.com"
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
def save_config(cfg):
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
yaml.dump(cfg, f, default_flow_style=False)
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
if FALLBACK_STATE.exists():
|
||||||
|
with open(FALLBACK_STATE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {"active_fallbacks": [], "last_check": None, "recovery_pending": False}
|
||||||
|
|
||||||
|
def save_state(state):
|
||||||
|
state["last_check"] = datetime.now().isoformat()
|
||||||
|
with open(FALLBACK_STATE, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
def run(cmd, timeout=10):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||||
|
return r.returncode, r.stdout.strip(), r.stderr.strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return -1, "", "timeout"
|
||||||
|
except Exception as e:
|
||||||
|
return -1, "", str(e)
|
||||||
|
|
||||||
|
# ─── HEALTH CHECKS ───
|
||||||
|
|
||||||
|
def check_kimi():
|
||||||
|
"""Can we reach Kimi Coding API?"""
|
||||||
|
key = os.environ.get("KIMI_API_KEY", "")
|
||||||
|
if not key:
|
||||||
|
# Check multiple .env locations
|
||||||
|
for env_path in [HERMES_HOME / ".env", Path.home() / ".hermes" / ".env"]:
|
||||||
|
if env_path.exists():
|
||||||
|
for line in open(env_path):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("KIMI_API_KEY="):
|
||||||
|
key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
break
|
||||||
|
if key:
|
||||||
|
break
|
||||||
|
if not key:
|
||||||
|
return False, "no API key"
|
||||||
|
code, out, err = run(
|
||||||
|
f'curl -s -o /dev/null -w "%{{http_code}}" -H "x-api-key: {key}" '
|
||||||
|
f'-H "x-api-provider: kimi-coding" '
|
||||||
|
f'https://api.kimi.com/coding/v1/models -X POST '
|
||||||
|
f'-H "content-type: application/json" '
|
||||||
|
f'-d \'{{"model":"kimi-k2.5","max_tokens":1,"messages":[{{"role":"user","content":"ping"}}]}}\' ',
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if code == 0 and out in ("200", "429"):
|
||||||
|
return True, f"HTTP {out}"
|
||||||
|
return False, f"HTTP {out} err={err[:80]}"
|
||||||
|
|
||||||
|
def check_local_llama():
|
||||||
|
"""Is local llama.cpp serving?"""
|
||||||
|
code, out, err = run("curl -s http://localhost:8081/v1/models", timeout=5)
|
||||||
|
if code == 0 and "hermes" in out.lower():
|
||||||
|
return True, "serving"
|
||||||
|
return False, f"exit={code}"
|
||||||
|
|
||||||
|
def check_ollama():
|
||||||
|
"""Is Ollama running?"""
|
||||||
|
code, out, err = run("curl -s http://localhost:11434/api/tags", timeout=5)
|
||||||
|
if code == 0 and "models" in out:
|
||||||
|
return True, "running"
|
||||||
|
return False, f"exit={code}"
|
||||||
|
|
||||||
|
def check_gitea():
|
||||||
|
"""Can we reach the Forge?"""
|
||||||
|
token_path = Path.home() / ".config" / "gitea" / "timmy-token"
|
||||||
|
if not token_path.exists():
|
||||||
|
return False, "no token"
|
||||||
|
token = token_path.read_text().strip()
|
||||||
|
code, out, err = run(
|
||||||
|
f'curl -s -o /dev/null -w "%{{http_code}}" -H "Authorization: token {token}" '
|
||||||
|
f'"{FORGE_URL}/api/v1/user"',
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if code == 0 and out == "200":
|
||||||
|
return True, "reachable"
|
||||||
|
return False, f"HTTP {out}"
|
||||||
|
|
||||||
|
def check_vps(ip, name):
|
||||||
|
"""Can we SSH into a VPS?"""
|
||||||
|
code, out, err = run(f"ssh -o ConnectTimeout=5 root@{ip} 'echo alive'", timeout=10)
|
||||||
|
if code == 0 and "alive" in out:
|
||||||
|
return True, "alive"
|
||||||
|
return False, f"unreachable"
|
||||||
|
|
||||||
|
# ─── FALLBACK ACTIONS ───
|
||||||
|
|
||||||
|
def fallback_to_local_model(cfg):
|
||||||
|
"""Switch primary model from Kimi to local llama.cpp"""
|
||||||
|
if not BACKUP_CONFIG.exists():
|
||||||
|
shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||||
|
|
||||||
|
cfg["model"]["provider"] = "local-llama.cpp"
|
||||||
|
cfg["model"]["default"] = "hermes3"
|
||||||
|
save_config(cfg)
|
||||||
|
return "Switched primary model to local-llama.cpp/hermes3"
|
||||||
|
|
||||||
|
def fallback_to_ollama(cfg):
|
||||||
|
"""Switch to Ollama if llama.cpp is also down"""
|
||||||
|
if not BACKUP_CONFIG.exists():
|
||||||
|
shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||||
|
|
||||||
|
cfg["model"]["provider"] = "ollama"
|
||||||
|
cfg["model"]["default"] = "gemma4:latest"
|
||||||
|
save_config(cfg)
|
||||||
|
return "Switched primary model to ollama/gemma4:latest"
|
||||||
|
|
||||||
|
def enter_safe_mode(state):
|
||||||
|
"""Pause all non-essential cron jobs, alert Alexander"""
|
||||||
|
state["safe_mode"] = True
|
||||||
|
state["safe_mode_entered"] = datetime.now().isoformat()
|
||||||
|
save_state(state)
|
||||||
|
return "SAFE MODE: All inference down. Cron jobs should be paused. Alert Alexander."
|
||||||
|
|
||||||
|
def restore_config():
|
||||||
|
"""Restore pre-fallback config when primary recovers"""
|
||||||
|
if BACKUP_CONFIG.exists():
|
||||||
|
shutil.copy2(BACKUP_CONFIG, CONFIG_PATH)
|
||||||
|
BACKUP_CONFIG.unlink()
|
||||||
|
return "Restored original config from backup"
|
||||||
|
return "No backup config to restore"
|
||||||
|
|
||||||
|
# ─── MAIN DIAGNOSIS AND FALLBACK ENGINE ───
|
||||||
|
|
||||||
|
def diagnose_and_fallback():
|
||||||
|
state = load_state()
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"checks": {},
|
||||||
|
"actions": [],
|
||||||
|
"status": "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check all systems
|
||||||
|
kimi_ok, kimi_msg = check_kimi()
|
||||||
|
results["checks"]["kimi-coding"] = {"ok": kimi_ok, "msg": kimi_msg}
|
||||||
|
|
||||||
|
llama_ok, llama_msg = check_local_llama()
|
||||||
|
results["checks"]["local_llama"] = {"ok": llama_ok, "msg": llama_msg}
|
||||||
|
|
||||||
|
ollama_ok, ollama_msg = check_ollama()
|
||||||
|
results["checks"]["ollama"] = {"ok": ollama_ok, "msg": ollama_msg}
|
||||||
|
|
||||||
|
gitea_ok, gitea_msg = check_gitea()
|
||||||
|
results["checks"]["gitea"] = {"ok": gitea_ok, "msg": gitea_msg}
|
||||||
|
|
||||||
|
# VPS checks
|
||||||
|
vpses = [
|
||||||
|
("167.99.126.228", "Allegro"),
|
||||||
|
("143.198.27.163", "Ezra"),
|
||||||
|
("159.203.146.185", "Bezalel"),
|
||||||
|
]
|
||||||
|
for ip, name in vpses:
|
||||||
|
vps_ok, vps_msg = check_vps(ip, name)
|
||||||
|
results["checks"][f"vps_{name.lower()}"] = {"ok": vps_ok, "msg": vps_msg}
|
||||||
|
|
||||||
|
current_provider = cfg.get("model", {}).get("provider", "kimi-coding")
|
||||||
|
|
||||||
|
# ─── FALLBACK LOGIC ───
|
||||||
|
|
||||||
|
# Case 1: Primary (Kimi) down, local available
|
||||||
|
if not kimi_ok and current_provider == "kimi-coding":
|
||||||
|
if llama_ok:
|
||||||
|
msg = fallback_to_local_model(cfg)
|
||||||
|
results["actions"].append(msg)
|
||||||
|
state["active_fallbacks"].append("kimi->local-llama")
|
||||||
|
results["status"] = "degraded_local"
|
||||||
|
elif ollama_ok:
|
||||||
|
msg = fallback_to_ollama(cfg)
|
||||||
|
results["actions"].append(msg)
|
||||||
|
state["active_fallbacks"].append("kimi->ollama")
|
||||||
|
results["status"] = "degraded_ollama"
|
||||||
|
else:
|
||||||
|
msg = enter_safe_mode(state)
|
||||||
|
results["actions"].append(msg)
|
||||||
|
results["status"] = "safe_mode"
|
||||||
|
|
||||||
|
# Case 2: Already on fallback, check if primary recovered
|
||||||
|
elif kimi_ok and "kimi->local-llama" in state.get("active_fallbacks", []):
|
||||||
|
msg = restore_config()
|
||||||
|
results["actions"].append(msg)
|
||||||
|
state["active_fallbacks"].remove("kimi->local-llama")
|
||||||
|
results["status"] = "recovered"
|
||||||
|
elif kimi_ok and "kimi->ollama" in state.get("active_fallbacks", []):
|
||||||
|
msg = restore_config()
|
||||||
|
results["actions"].append(msg)
|
||||||
|
state["active_fallbacks"].remove("kimi->ollama")
|
||||||
|
results["status"] = "recovered"
|
||||||
|
|
||||||
|
# Case 3: Gitea down — just flag it, work locally
|
||||||
|
if not gitea_ok:
|
||||||
|
results["actions"].append("WARN: Gitea unreachable — work cached locally until recovery")
|
||||||
|
if "gitea_down" not in state.get("active_fallbacks", []):
|
||||||
|
state["active_fallbacks"].append("gitea_down")
|
||||||
|
results["status"] = max(results["status"], "degraded_gitea", key=lambda x: ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"].index(x) if x in ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"] else 0)
|
||||||
|
elif "gitea_down" in state.get("active_fallbacks", []):
|
||||||
|
state["active_fallbacks"].remove("gitea_down")
|
||||||
|
results["actions"].append("Gitea recovered — resume normal operations")
|
||||||
|
|
||||||
|
# Case 4: VPS agents down
|
||||||
|
for ip, name in vpses:
|
||||||
|
key = f"vps_{name.lower()}"
|
||||||
|
if not results["checks"][key]["ok"]:
|
||||||
|
results["actions"].append(f"ALERT: {name} VPS ({ip}) unreachable — lazarus protocol needed")
|
||||||
|
|
||||||
|
save_state(state)
|
||||||
|
return results
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
results = diagnose_and_fallback()
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
|
||||||
|
# Exit codes for cron integration
|
||||||
|
if results["status"] == "safe_mode":
|
||||||
|
sys.exit(2)
|
||||||
|
elif results["status"].startswith("degraded"):
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
sys.exit(0)
|
||||||
439
bin/glitch_patterns.py
Normal file
439
bin/glitch_patterns.py
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
"""
|
||||||
|
Glitch pattern definitions for 3D world anomaly detection.
|
||||||
|
|
||||||
|
Defines known visual artifact categories commonly found in 3D web worlds,
|
||||||
|
particularly The Matrix environments. Each pattern includes detection
|
||||||
|
heuristics and severity ratings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchSeverity(Enum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
INFO = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchCategory(Enum):
|
||||||
|
FLOATING_ASSETS = "floating_assets"
|
||||||
|
Z_FIGHTING = "z_fighting"
|
||||||
|
MISSING_TEXTURES = "missing_textures"
|
||||||
|
CLIPPING = "clipping"
|
||||||
|
BROKEN_NORMALS = "broken_normals"
|
||||||
|
SHADOW_ARTIFACTS = "shadow_artifacts"
|
||||||
|
LIGHTMAP_ERRORS = "lightmap_errors"
|
||||||
|
LOD_POPPING = "lod_popping"
|
||||||
|
WATER_REFLECTION = "water_reflection"
|
||||||
|
SKYBOX_SEAM = "skybox_seam"
|
||||||
|
|
||||||
|
# Three.js-specific categories (ref: timmy-config#543)
|
||||||
|
SHADER_FAILURE = "shader_failure"
|
||||||
|
TEXTURE_PLACEHOLDER = "texture_placeholder"
|
||||||
|
UV_MAPPING_ERROR = "uv_mapping_error"
|
||||||
|
FRUSTUM_CULLING = "frustum_culling"
|
||||||
|
SHADOW_MAP_ARTIFACT = "shadow_map_artifact"
|
||||||
|
BLOOM_OVERFLOW = "bloom_overflow"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GlitchPattern:
|
||||||
|
"""Definition of a known glitch pattern with detection parameters."""
|
||||||
|
category: GlitchCategory
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
severity: GlitchSeverity
|
||||||
|
detection_prompts: list[str]
|
||||||
|
visual_indicators: list[str]
|
||||||
|
confidence_threshold: float = 0.6
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"category": self.category.value,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"severity": self.severity.value,
|
||||||
|
"detection_prompts": self.detection_prompts,
|
||||||
|
"visual_indicators": self.visual_indicators,
|
||||||
|
"confidence_threshold": self.confidence_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Known glitch patterns for Matrix 3D world scanning
|
||||||
|
MATRIX_GLITCH_PATTERNS: list[GlitchPattern] = [
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.FLOATING_ASSETS,
|
||||||
|
name="Floating Object",
|
||||||
|
description="Object not properly grounded or anchored to the scene geometry. "
|
||||||
|
"Common in procedurally placed assets or after physics desync.",
|
||||||
|
severity=GlitchSeverity.HIGH,
|
||||||
|
detection_prompts=[
|
||||||
|
"Identify any objects that appear to float above the ground without support.",
|
||||||
|
"Look for furniture, props, or geometry suspended in mid-air with no visible attachment.",
|
||||||
|
"Check for objects whose shadows do not align with the surface below them.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"gap between object base and surface",
|
||||||
|
"shadow detached from object",
|
||||||
|
"object hovering with no structural support",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.65,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.Z_FIGHTING,
|
||||||
|
name="Z-Fighting Flicker",
|
||||||
|
description="Two coplanar surfaces competing for depth priority, causing "
|
||||||
|
"visible flickering or shimmering textures.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for surfaces that appear to shimmer, flicker, or show mixed textures.",
|
||||||
|
"Identify areas where two textures seem to overlap and compete for visibility.",
|
||||||
|
"Check walls, floors, or objects for surface noise or pattern interference.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"shimmering surface",
|
||||||
|
"texture flicker between two patterns",
|
||||||
|
"noisy flat surfaces",
|
||||||
|
"moire-like patterns on planar geometry",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.55,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.MISSING_TEXTURES,
|
||||||
|
name="Missing or Placeholder Texture",
|
||||||
|
description="A surface rendered with a fallback checkerboard, solid magenta, "
|
||||||
|
"or the default engine placeholder texture.",
|
||||||
|
severity=GlitchSeverity.CRITICAL,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for bright magenta, checkerboard, or solid-color surfaces that look out of place.",
|
||||||
|
"Identify any surfaces that appear as flat untextured colors inconsistent with the scene.",
|
||||||
|
"Check for black, white, or magenta patches where detailed textures should be.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"magenta/pink solid color surface",
|
||||||
|
"checkerboard pattern",
|
||||||
|
"flat single-color geometry",
|
||||||
|
"UV-debug texture visible",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.7,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.CLIPPING,
|
||||||
|
name="Geometry Clipping",
|
||||||
|
description="Objects passing through each other or intersecting in physically "
|
||||||
|
"impossible ways due to collision mesh errors.",
|
||||||
|
severity=GlitchSeverity.HIGH,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for objects that visibly pass through other objects (walls, floors, furniture).",
|
||||||
|
"Identify characters or props embedded inside geometry where they should not be.",
|
||||||
|
"Check for intersecting meshes where solid objects overlap unnaturally.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"object passing through wall or floor",
|
||||||
|
"embedded geometry",
|
||||||
|
"overlapping solid meshes",
|
||||||
|
"character limb inside furniture",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.6,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.BROKEN_NORMALS,
|
||||||
|
name="Broken Surface Normals",
|
||||||
|
description="Inverted or incorrect surface normals causing faces to appear "
|
||||||
|
"inside-out, invisible from certain angles, or lit incorrectly.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for surfaces that appear dark or black on one side while lit on the other.",
|
||||||
|
"Identify objects that seem to vanish when viewed from certain angles.",
|
||||||
|
"Check for inverted shading where lit areas should be in shadow.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"dark/unlit face on otherwise lit model",
|
||||||
|
"invisible surface from one direction",
|
||||||
|
"inverted shadow gradient",
|
||||||
|
"inside-out appearance",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.SHADOW_ARTIFACTS,
|
||||||
|
name="Shadow Artifact",
|
||||||
|
description="Broken, detached, or incorrectly rendered shadows that do not "
|
||||||
|
"match the casting geometry or scene lighting.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for shadows that do not match the shape of nearby objects.",
|
||||||
|
"Identify shadow acne: banding or striped patterns on surfaces.",
|
||||||
|
"Check for floating shadows detached from any visible caster.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"shadow shape mismatch",
|
||||||
|
"shadow acne bands",
|
||||||
|
"detached floating shadow",
|
||||||
|
"Peter Panning (shadow offset from base)",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.LOD_POPPING,
|
||||||
|
name="LOD Transition Pop",
|
||||||
|
description="Visible pop-in when level-of-detail models switch abruptly, "
|
||||||
|
"causing geometry or textures to change suddenly.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for areas where mesh detail changes abruptly at visible boundaries.",
|
||||||
|
"Identify objects that appear to morph or shift geometry suddenly.",
|
||||||
|
"Check for texture resolution changes that create visible seams.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"visible mesh simplification boundary",
|
||||||
|
"texture resolution jump",
|
||||||
|
"geometry pop-in artifacts",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.45,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.LIGHTMAP_ERRORS,
|
||||||
|
name="Lightmap Baking Error",
|
||||||
|
description="Incorrect or missing baked lighting causing dark spots, light "
|
||||||
|
"leaks, or mismatched illumination on static geometry.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for unusually dark patches on walls or ceilings that should be lit.",
|
||||||
|
"Identify bright light leaks through solid geometry seams.",
|
||||||
|
"Check for mismatched lighting between adjacent surfaces.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"dark splotch on lit surface",
|
||||||
|
"bright line at geometry seam",
|
||||||
|
"lighting discontinuity between adjacent faces",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.WATER_REFLECTION,
|
||||||
|
name="Water/Reflection Error",
|
||||||
|
description="Incorrect reflections, missing water surfaces, or broken "
|
||||||
|
"reflection probe assignments.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for reflections that do not match the surrounding environment.",
|
||||||
|
"Identify water surfaces that appear solid or incorrectly rendered.",
|
||||||
|
"Check for mirror surfaces showing wrong scene geometry.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"reflection mismatch",
|
||||||
|
"solid water surface",
|
||||||
|
"incorrect environment map",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.SKYBOX_SEAM,
|
||||||
|
name="Skybox Seam",
|
||||||
|
description="Visible seams or color mismatches at the edges of skybox cubemap faces.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look at the edges of the sky for visible seams or color shifts.",
|
||||||
|
"Identify discontinuities where skybox faces meet.",
|
||||||
|
"Check for texture stretching at skybox corners.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"visible line in sky",
|
||||||
|
"color discontinuity at sky edge",
|
||||||
|
"sky texture seam",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.45,
|
||||||
|
),
|
||||||
|
|
||||||
|
# --- Three.js-Specific Glitch Patterns (ref: timmy-config#543) ---
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.SHADER_FAILURE,
|
||||||
|
name="Shader Compilation Failure",
|
||||||
|
description="Three.js shader failed to compile, rendering the material as solid black. "
|
||||||
|
"Common when custom ShaderMaterial has syntax errors or missing uniforms.",
|
||||||
|
severity=GlitchSeverity.CRITICAL,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for objects or surfaces rendered as pure black (#000000) that should have visible textures or materials.",
|
||||||
|
"Identify geometry that appears completely dark while surrounding objects are normally lit.",
|
||||||
|
"Check for objects where the material seems to 'absorb all light' — flat black with no shading gradient.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"solid black object with no shading",
|
||||||
|
"geometry rendered as silhouette",
|
||||||
|
"material appears to absorb light entirely",
|
||||||
|
"black patch inconsistent with scene lighting",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.7,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.TEXTURE_PLACEHOLDER,
|
||||||
|
name="Three.js Texture Not Loaded",
|
||||||
|
description="Three.js failed to load the texture asset, rendering a 1x1 white pixel "
|
||||||
|
"stretched across the entire surface. Distinguished from missing-texture by "
|
||||||
|
"the uniform white/grey appearance rather than magenta.",
|
||||||
|
severity=GlitchSeverity.CRITICAL,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for surfaces that are uniformly white or light grey with no texture detail, even on large geometry.",
|
||||||
|
"Identify objects where the texture appears as a single solid color stretched across complex UVs.",
|
||||||
|
"Check for surfaces that look 'blank' or 'unloaded' — flat white/grey where detail should exist.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"uniform white or light grey surface",
|
||||||
|
"no texture detail on large geometry",
|
||||||
|
"stretched single-color appearance",
|
||||||
|
"1x1 pixel placeholder stretched to fill UV space",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.65,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.UV_MAPPING_ERROR,
|
||||||
|
name="BufferGeometry UV Mapping Error",
|
||||||
|
description="Three.js BufferGeometry has incorrect UV coordinates, causing textures to "
|
||||||
|
"appear stretched, compressed, or mapped to the wrong faces.",
|
||||||
|
severity=GlitchSeverity.HIGH,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for textures that appear dramatically stretched in one direction on specific faces.",
|
||||||
|
"Identify surfaces where the texture pattern is distorted but other nearby surfaces look correct.",
|
||||||
|
"Check for faces where the texture seems 'smeared' or mapped with incorrect aspect ratio.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"texture stretching on specific faces",
|
||||||
|
"distorted pattern on geometry",
|
||||||
|
"smeared texture appearance",
|
||||||
|
"aspect ratio mismatch between texture and surface",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.6,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.FRUSTUM_CULLING,
|
||||||
|
name="Frustum Culling Artifact",
|
||||||
|
description="Three.js frustum culling incorrectly marks objects as outside the camera "
|
||||||
|
"frustum, causing them to pop in/out of existence at screen edges.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for objects that are partially visible at the edge of the frame — half-rendered or cut off unnaturally.",
|
||||||
|
"Identify geometry that seems to 'pop' into existence as the view angle changes.",
|
||||||
|
"Check screen edges for objects that appear suddenly rather than smoothly entering the viewport.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"half-visible object at screen edge",
|
||||||
|
"object popping into frame",
|
||||||
|
"abrupt appearance of geometry",
|
||||||
|
"bounding box visible but mesh missing",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.55,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.SHADOW_MAP_ARTIFACT,
|
||||||
|
name="Shadow Map Resolution Artifact",
|
||||||
|
description="Three.js shadow map has insufficient resolution, causing pixelated, "
|
||||||
|
"blocky shadows with visible texel edges instead of smooth shadow gradients.",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for shadows with visible blocky or pixelated edges instead of smooth gradients.",
|
||||||
|
"Identify shadow maps where individual texels (texture pixels) are clearly visible.",
|
||||||
|
"Check for shadows that appear as jagged stair-stepped patterns rather than soft edges.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"blocky shadow edges",
|
||||||
|
"visible texel grid in shadows",
|
||||||
|
"stair-stepped shadow boundary",
|
||||||
|
"pixelated shadow gradient",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.55,
|
||||||
|
),
|
||||||
|
GlitchPattern(
|
||||||
|
category=GlitchCategory.BLOOM_OVERFLOW,
|
||||||
|
name="Post-Processing Bloom Overflow",
|
||||||
|
description="Three.js UnrealBloomPass or similar post-processing bloom effect is too "
|
||||||
|
"intense, causing bright areas to bleed glow into surrounding geometry.",
|
||||||
|
severity=GlitchSeverity.LOW,
|
||||||
|
detection_prompts=[
|
||||||
|
"Look for bright areas that have an unusually large, soft glow bleeding into adjacent surfaces.",
|
||||||
|
"Identify scenes where light sources appear to have a 'halo' that extends beyond physical plausibility.",
|
||||||
|
"Check for bright objects whose glow color bleeds onto nearby unrelated geometry.",
|
||||||
|
],
|
||||||
|
visual_indicators=[
|
||||||
|
"excessive glow bleeding from bright surfaces",
|
||||||
|
"halo around light sources",
|
||||||
|
"bloom color tinting adjacent geometry",
|
||||||
|
"glow bleeding beyond object boundaries",
|
||||||
|
],
|
||||||
|
confidence_threshold=0.5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_patterns_by_severity(min_severity: GlitchSeverity) -> list[GlitchPattern]:
|
||||||
|
"""Return patterns at or above the given severity level."""
|
||||||
|
severity_order = [
|
||||||
|
GlitchSeverity.INFO,
|
||||||
|
GlitchSeverity.LOW,
|
||||||
|
GlitchSeverity.MEDIUM,
|
||||||
|
GlitchSeverity.HIGH,
|
||||||
|
GlitchSeverity.CRITICAL,
|
||||||
|
]
|
||||||
|
min_idx = severity_order.index(min_severity)
|
||||||
|
return [p for p in MATRIX_GLITCH_PATTERNS if severity_order.index(p.severity) >= min_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pattern_by_category(category: GlitchCategory) -> Optional[GlitchPattern]:
|
||||||
|
"""Return the pattern definition for a specific category."""
|
||||||
|
for p in MATRIX_GLITCH_PATTERNS:
|
||||||
|
if p.category == category:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_vision_prompt(patterns: list[GlitchPattern] | None = None) -> str:
|
||||||
|
"""Build a composite vision analysis prompt from pattern definitions."""
|
||||||
|
if patterns is None:
|
||||||
|
patterns = MATRIX_GLITCH_PATTERNS
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for p in patterns:
|
||||||
|
prompt_text = " ".join(p.detection_prompts)
|
||||||
|
indicators = ", ".join(p.visual_indicators)
|
||||||
|
sections.append(
|
||||||
|
f"[{p.category.value.upper()}] {p.name} (severity: {p.severity.value})\n"
|
||||||
|
f" {p.description}\n"
|
||||||
|
f" Look for: {prompt_text}\n"
|
||||||
|
f" Visual indicators: {indicators}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"Analyze this 3D world screenshot for visual glitches and artifacts. "
|
||||||
|
"For each detected issue, report the category, description of what you see, "
|
||||||
|
"approximate location in the image (x%, y%), and confidence (0.0-1.0).\n\n"
|
||||||
|
"Known glitch patterns to check:\n\n" + "\n\n".join(sections)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Three.js-specific category set for filtering (ref: timmy-config#543)
|
||||||
|
THREEJS_CATEGORIES = {
|
||||||
|
GlitchCategory.SHADER_FAILURE,
|
||||||
|
GlitchCategory.TEXTURE_PLACEHOLDER,
|
||||||
|
GlitchCategory.UV_MAPPING_ERROR,
|
||||||
|
GlitchCategory.FRUSTUM_CULLING,
|
||||||
|
GlitchCategory.SHADOW_MAP_ARTIFACT,
|
||||||
|
GlitchCategory.BLOOM_OVERFLOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_threejs_patterns() -> list[GlitchPattern]:
|
||||||
|
"""Return only Three.js-specific glitch patterns."""
|
||||||
|
return [p for p in MATRIX_GLITCH_PATTERNS if p.category in THREEJS_CATEGORIES]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
print(f"Loaded {len(MATRIX_GLITCH_PATTERNS)} glitch patterns:\n")
|
||||||
|
for p in MATRIX_GLITCH_PATTERNS:
|
||||||
|
print(f" [{p.severity.value:8s}] {p.category.value}: {p.name}")
|
||||||
|
print(f"\nVision prompt preview:\n{build_vision_prompt()[:500]}...")
|
||||||
586
bin/matrix_glitch_detector.py
Normal file
586
bin/matrix_glitch_detector.py
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Matrix 3D World Glitch Detector
|
||||||
|
|
||||||
|
Scans a 3D web world for visual artifacts using browser automation
|
||||||
|
and vision AI analysis. Produces structured glitch reports.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python matrix_glitch_detector.py <url> [--angles 4] [--output report.json]
|
||||||
|
python matrix_glitch_detector.py --demo # Run with synthetic test data
|
||||||
|
|
||||||
|
Ref: timmy-config#491, timmy-config#543
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Add parent for glitch_patterns import
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from glitch_patterns import (
|
||||||
|
GlitchCategory,
|
||||||
|
GlitchPattern,
|
||||||
|
GlitchSeverity,
|
||||||
|
MATRIX_GLITCH_PATTERNS,
|
||||||
|
build_vision_prompt,
|
||||||
|
get_patterns_by_severity,
|
||||||
|
get_threejs_patterns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectedGlitch:
|
||||||
|
"""A single detected glitch with metadata."""
|
||||||
|
id: str
|
||||||
|
category: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
severity: str
|
||||||
|
confidence: float
|
||||||
|
location_x: Optional[float] = None # percentage across image
|
||||||
|
location_y: Optional[float] = None # percentage down image
|
||||||
|
screenshot_index: int = 0
|
||||||
|
screenshot_angle: str = "front"
|
||||||
|
timestamp: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.timestamp:
|
||||||
|
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
"""Complete scan result for a 3D world URL."""
|
||||||
|
scan_id: str
|
||||||
|
url: str
|
||||||
|
timestamp: str
|
||||||
|
total_screenshots: int
|
||||||
|
angles_captured: list[str]
|
||||||
|
glitches: list[dict] = field(default_factory=list)
|
||||||
|
summary: dict = field(default_factory=dict)
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_json(self, indent: int = 2) -> str:
|
||||||
|
return json.dumps(asdict(self), indent=indent)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_scan_angles(num_angles: int) -> list[dict]:
|
||||||
|
"""Generate camera angle configurations for multi-angle scanning.
|
||||||
|
|
||||||
|
Returns a list of dicts with yaw/pitch/label for browser camera control.
|
||||||
|
"""
|
||||||
|
base_angles = [
|
||||||
|
{"yaw": 0, "pitch": 0, "label": "front"},
|
||||||
|
{"yaw": 90, "pitch": 0, "label": "right"},
|
||||||
|
{"yaw": 180, "pitch": 0, "label": "back"},
|
||||||
|
{"yaw": 270, "pitch": 0, "label": "left"},
|
||||||
|
{"yaw": 0, "pitch": -30, "label": "front_low"},
|
||||||
|
{"yaw": 45, "pitch": -15, "label": "front_right_low"},
|
||||||
|
{"yaw": 0, "pitch": 30, "label": "front_high"},
|
||||||
|
{"yaw": 45, "pitch": 0, "label": "front_right"},
|
||||||
|
]
|
||||||
|
|
||||||
|
if num_angles <= len(base_angles):
|
||||||
|
return base_angles[:num_angles]
|
||||||
|
return base_angles + [
|
||||||
|
{"yaw": i * (360 // num_angles), "pitch": 0, "label": f"angle_{i}"}
|
||||||
|
for i in range(len(base_angles), num_angles)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def capture_screenshots(url: str, angles: list[dict], output_dir: Path) -> list[Path]:
|
||||||
|
"""Capture screenshots of a 3D web world from multiple angles.
|
||||||
|
|
||||||
|
Uses browser_vision tool when available; falls back to placeholder generation
|
||||||
|
for testing and environments without browser access.
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
screenshots = []
|
||||||
|
|
||||||
|
for i, angle in enumerate(angles):
|
||||||
|
filename = output_dir / f"screenshot_{i:03d}_{angle['label']}.png"
|
||||||
|
|
||||||
|
# Attempt browser-based capture via browser_vision
|
||||||
|
try:
|
||||||
|
result = _browser_capture(url, angle, filename)
|
||||||
|
if result:
|
||||||
|
screenshots.append(filename)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Generate placeholder screenshot for offline/test scenarios
|
||||||
|
_generate_placeholder_screenshot(filename, angle)
|
||||||
|
screenshots.append(filename)
|
||||||
|
|
||||||
|
return screenshots
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_capture(url: str, angle: dict, output_path: Path) -> bool:
|
||||||
|
"""Capture a screenshot via browser automation.
|
||||||
|
|
||||||
|
This is a stub that delegates to the browser_vision tool when run
|
||||||
|
in an environment that provides it. In CI or offline mode, returns False.
|
||||||
|
"""
|
||||||
|
# Check if browser_vision is available via environment
|
||||||
|
bv_script = os.environ.get("BROWSER_VISION_SCRIPT")
|
||||||
|
if bv_script and Path(bv_script).exists():
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
sys.executable, bv_script,
|
||||||
|
"--url", url,
|
||||||
|
"--screenshot", str(output_path),
|
||||||
|
"--rotate-yaw", str(angle["yaw"]),
|
||||||
|
"--rotate-pitch", str(angle["pitch"]),
|
||||||
|
]
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
return proc.returncode == 0 and output_path.exists()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_placeholder_screenshot(path: Path, angle: dict):
|
||||||
|
"""Generate a minimal 1x1 PNG as a placeholder for testing."""
|
||||||
|
# Minimal valid PNG (1x1 transparent pixel)
|
||||||
|
png_data = (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||||
|
b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
|
||||||
|
b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
)
|
||||||
|
path.write_bytes(png_data)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_with_vision(
|
||||||
|
screenshot_paths: list[Path],
|
||||||
|
angles: list[dict],
|
||||||
|
patterns: list[GlitchPattern] | None = None,
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Send screenshots to vision AI for glitch analysis.
|
||||||
|
|
||||||
|
In environments with a vision model available, sends each screenshot
|
||||||
|
with the composite detection prompt. Otherwise returns simulated results.
|
||||||
|
"""
|
||||||
|
if patterns is None:
|
||||||
|
patterns = MATRIX_GLITCH_PATTERNS
|
||||||
|
|
||||||
|
prompt = build_vision_prompt(patterns)
|
||||||
|
glitches = []
|
||||||
|
|
||||||
|
for i, (path, angle) in enumerate(zip(screenshot_paths, angles)):
|
||||||
|
# Attempt vision analysis
|
||||||
|
detected = _vision_analyze_image(path, prompt, i, angle["label"])
|
||||||
|
glitches.extend(detected)
|
||||||
|
|
||||||
|
return glitches
|
||||||
|
|
||||||
|
|
||||||
|
def _vision_analyze_image(
|
||||||
|
image_path: Path,
|
||||||
|
prompt: str,
|
||||||
|
screenshot_index: int,
|
||||||
|
angle_label: str,
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Analyze a single screenshot with vision AI.
|
||||||
|
|
||||||
|
Uses the vision_analyze tool when available; returns empty list otherwise.
|
||||||
|
"""
|
||||||
|
# Check for vision API configuration
|
||||||
|
api_key = os.environ.get("VISION_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||||
|
api_base = os.environ.get("VISION_API_BASE", "https://api.openai.com/v1")
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
try:
|
||||||
|
return _call_vision_api(
|
||||||
|
image_path, prompt, screenshot_index, angle_label, api_key, api_base
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [!] Vision API error for {image_path.name}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# No vision backend available
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _call_vision_api(
|
||||||
|
image_path: Path,
|
||||||
|
prompt: str,
|
||||||
|
screenshot_index: int,
|
||||||
|
angle_label: str,
|
||||||
|
api_key: str,
|
||||||
|
api_base: str,
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Call a vision API (OpenAI-compatible) for image analysis."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
image_data = base64.b64encode(image_path.read_bytes()).decode()
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": os.environ.get("VISION_MODEL", "gpt-4o"),
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{image_data}",
|
||||||
|
"detail": "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_tokens": 4096,
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{api_base}/chat/completions",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
|
||||||
|
content = result["choices"][0]["message"]["content"]
|
||||||
|
return _parse_vision_response(content, screenshot_index, angle_label)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_glitch_from_dict(
|
||||||
|
item: dict,
|
||||||
|
glitches: list[DetectedGlitch],
|
||||||
|
screenshot_index: int,
|
||||||
|
angle_label: str,
|
||||||
|
):
|
||||||
|
"""Convert a dict from vision API response into a DetectedGlitch."""
|
||||||
|
cat = item.get("category", item.get("type", "unknown"))
|
||||||
|
conf = float(item.get("confidence", item.get("score", 0.5)))
|
||||||
|
|
||||||
|
glitch = DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category=cat,
|
||||||
|
name=item.get("name", item.get("label", cat)),
|
||||||
|
description=item.get("description", item.get("detail", "")),
|
||||||
|
severity=item.get("severity", _infer_severity(cat, conf)),
|
||||||
|
confidence=conf,
|
||||||
|
location_x=item.get("location_x", item.get("x")),
|
||||||
|
location_y=item.get("location_y", item.get("y")),
|
||||||
|
screenshot_index=screenshot_index,
|
||||||
|
screenshot_angle=angle_label,
|
||||||
|
)
|
||||||
|
glitches.append(glitch)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vision_response(
|
||||||
|
text: str, screenshot_index: int, angle_label: str
|
||||||
|
) -> list[DetectedGlitch]:
|
||||||
|
"""Parse vision AI response into structured glitch detections."""
|
||||||
|
glitches = []
|
||||||
|
|
||||||
|
# Try to extract JSON from the response
|
||||||
|
json_blocks = []
|
||||||
|
in_json = False
|
||||||
|
json_buf = []
|
||||||
|
|
||||||
|
for line in text.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
if in_json and json_buf:
|
||||||
|
try:
|
||||||
|
json_blocks.append(json.loads("\n".join(json_buf)))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
json_buf = []
|
||||||
|
in_json = not in_json
|
||||||
|
continue
|
||||||
|
if in_json:
|
||||||
|
json_buf.append(line)
|
||||||
|
|
||||||
|
# Flush any remaining buffer
|
||||||
|
if in_json and json_buf:
|
||||||
|
try:
|
||||||
|
json_blocks.append(json.loads("\n".join(json_buf)))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also try parsing the entire response as JSON
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
json_blocks.extend(parsed)
|
||||||
|
elif isinstance(parsed, dict):
|
||||||
|
if "glitches" in parsed:
|
||||||
|
json_blocks.extend(parsed["glitches"])
|
||||||
|
elif "detections" in parsed:
|
||||||
|
json_blocks.extend(parsed["detections"])
|
||||||
|
else:
|
||||||
|
json_blocks.append(parsed)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for item in json_blocks:
|
||||||
|
# Flatten arrays of detections
|
||||||
|
if isinstance(item, list):
|
||||||
|
for sub in item:
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
_add_glitch_from_dict(sub, glitches, screenshot_index, angle_label)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
_add_glitch_from_dict(item, glitches, screenshot_index, angle_label)
|
||||||
|
|
||||||
|
return glitches
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_severity(category: str, confidence: float) -> str:
|
||||||
|
"""Infer severity from category and confidence when not provided."""
|
||||||
|
critical_cats = {"missing_textures", "clipping", "shader_failure", "texture_placeholder"}
|
||||||
|
high_cats = {"floating_assets", "broken_normals", "uv_mapping_error"}
|
||||||
|
medium_cats = {"frustum_culling", "shadow_map_artifact"}
|
||||||
|
|
||||||
|
cat_lower = category.lower()
|
||||||
|
if any(c in cat_lower for c in critical_cats):
|
||||||
|
return "critical" if confidence > 0.7 else "high"
|
||||||
|
if any(c in cat_lower for c in high_cats):
|
||||||
|
return "high" if confidence > 0.7 else "medium"
|
||||||
|
if any(c in cat_lower for c in medium_cats):
|
||||||
|
return "medium" if confidence > 0.6 else "low"
|
||||||
|
return "medium" if confidence > 0.6 else "low"
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
url: str,
|
||||||
|
angles: list[dict],
|
||||||
|
screenshots: list[Path],
|
||||||
|
glitches: list[DetectedGlitch],
|
||||||
|
) -> ScanResult:
|
||||||
|
"""Build the final structured scan report."""
|
||||||
|
severity_counts = {}
|
||||||
|
category_counts = {}
|
||||||
|
|
||||||
|
for g in glitches:
|
||||||
|
severity_counts[g.severity] = severity_counts.get(g.severity, 0) + 1
|
||||||
|
category_counts[g.category] = category_counts.get(g.category, 0) + 1
|
||||||
|
|
||||||
|
report = ScanResult(
|
||||||
|
scan_id=str(uuid.uuid4()),
|
||||||
|
url=url,
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
total_screenshots=len(screenshots),
|
||||||
|
angles_captured=[a["label"] for a in angles],
|
||||||
|
glitches=[asdict(g) for g in glitches],
|
||||||
|
summary={
|
||||||
|
"total_glitches": len(glitches),
|
||||||
|
"by_severity": severity_counts,
|
||||||
|
"by_category": category_counts,
|
||||||
|
"highest_severity": max(severity_counts.keys(), default="none"),
|
||||||
|
"clean_screenshots": sum(
|
||||||
|
1
|
||||||
|
for i in range(len(screenshots))
|
||||||
|
if not any(g.screenshot_index == i for g in glitches)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"detector_version": "0.2.0",
|
||||||
|
"pattern_count": len(MATRIX_GLITCH_PATTERNS),
|
||||||
|
"reference": "timmy-config#491, timmy-config#543",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def run_demo(output_path: Optional[Path] = None) -> ScanResult:
|
||||||
|
"""Run a demonstration scan with simulated detections."""
|
||||||
|
print("[*] Running Matrix glitch detection demo...")
|
||||||
|
|
||||||
|
url = "https://matrix.example.com/world/alpha"
|
||||||
|
angles = generate_scan_angles(4)
|
||||||
|
screenshots_dir = Path("/tmp/matrix_glitch_screenshots")
|
||||||
|
|
||||||
|
print(f"[*] Capturing {len(angles)} screenshots from: {url}")
|
||||||
|
screenshots = capture_screenshots(url, angles, screenshots_dir)
|
||||||
|
print(f"[*] Captured {len(screenshots)} screenshots")
|
||||||
|
|
||||||
|
# Simulate detections for demo
|
||||||
|
demo_glitches = [
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="floating_assets",
|
||||||
|
name="Floating Chair",
|
||||||
|
description="Office chair floating 0.3m above floor in sector 7",
|
||||||
|
severity="high",
|
||||||
|
confidence=0.87,
|
||||||
|
location_x=35.2,
|
||||||
|
location_y=62.1,
|
||||||
|
screenshot_index=0,
|
||||||
|
screenshot_angle="front",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="z_fighting",
|
||||||
|
name="Wall Texture Flicker",
|
||||||
|
description="Z-fighting between wall panel and decorative overlay",
|
||||||
|
severity="medium",
|
||||||
|
confidence=0.72,
|
||||||
|
location_x=58.0,
|
||||||
|
location_y=40.5,
|
||||||
|
screenshot_index=1,
|
||||||
|
screenshot_angle="right",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="missing_textures",
|
||||||
|
name="Placeholder Texture",
|
||||||
|
description="Bright magenta surface on door frame — missing asset reference",
|
||||||
|
severity="critical",
|
||||||
|
confidence=0.95,
|
||||||
|
location_x=72.3,
|
||||||
|
location_y=28.8,
|
||||||
|
screenshot_index=2,
|
||||||
|
screenshot_angle="back",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="clipping",
|
||||||
|
name="Desk Through Wall",
|
||||||
|
description="Desk corner clipping through adjacent wall geometry",
|
||||||
|
severity="high",
|
||||||
|
confidence=0.81,
|
||||||
|
location_x=15.0,
|
||||||
|
location_y=55.0,
|
||||||
|
screenshot_index=3,
|
||||||
|
screenshot_angle="left",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="shader_failure",
|
||||||
|
name="Black Material on Portal Frame",
|
||||||
|
description="Portal frame rendered as solid black — shader compilation failed (missing uniform u_time)",
|
||||||
|
severity="critical",
|
||||||
|
confidence=0.91,
|
||||||
|
location_x=45.0,
|
||||||
|
location_y=30.0,
|
||||||
|
screenshot_index=0,
|
||||||
|
screenshot_angle="front",
|
||||||
|
),
|
||||||
|
DetectedGlitch(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
category="shadow_map_artifact",
|
||||||
|
name="Pixelated Character Shadow",
|
||||||
|
description="Character shadow shows visible texel grid — shadow map resolution too low (512x512)",
|
||||||
|
severity="medium",
|
||||||
|
confidence=0.78,
|
||||||
|
location_x=52.0,
|
||||||
|
location_y=75.0,
|
||||||
|
screenshot_index=1,
|
||||||
|
screenshot_angle="right",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[*] Detected {len(demo_glitches)} glitches")
|
||||||
|
report = build_report(url, angles, screenshots, demo_glitches)
|
||||||
|
|
||||||
|
if output_path:
|
||||||
|
output_path.write_text(report.to_json())
|
||||||
|
print(f"[*] Report saved to: {output_path}")
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Matrix 3D World Glitch Detector — scan for visual artifacts",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s https://matrix.example.com/world/alpha
|
||||||
|
%(prog)s https://matrix.example.com/world/alpha --angles 8 --output report.json
|
||||||
|
%(prog)s --demo
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("url", nargs="?", help="URL of the 3D world to scan")
|
||||||
|
parser.add_argument(
|
||||||
|
"--angles", type=int, default=4, help="Number of camera angles to capture (default: 4)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", "-o", type=str, help="Output file path for JSON report")
|
||||||
|
parser.add_argument("--demo", action="store_true", help="Run demo with simulated data")
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-severity",
|
||||||
|
choices=["info", "low", "medium", "high", "critical"],
|
||||||
|
default="info",
|
||||||
|
help="Minimum severity to include in report",
|
||||||
|
)
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
||||||
|
parser.add_argument(
|
||||||
|
"--threejs",
|
||||||
|
action="store_true",
|
||||||
|
help="Focus on Three.js-specific glitch patterns only (shader, texture, UV, culling, shadow, bloom)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.demo:
|
||||||
|
output = Path(args.output) if args.output else Path("glitch_report_demo.json")
|
||||||
|
report = run_demo(output)
|
||||||
|
print(f"\n=== Scan Summary ===")
|
||||||
|
print(f"URL: {report.url}")
|
||||||
|
print(f"Screenshots: {report.total_screenshots}")
|
||||||
|
print(f"Glitches found: {report.summary['total_glitches']}")
|
||||||
|
print(f"By severity: {report.summary['by_severity']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.url:
|
||||||
|
parser.error("URL required (or use --demo)")
|
||||||
|
|
||||||
|
scan_id = str(uuid.uuid4())[:8]
|
||||||
|
print(f"[*] Matrix Glitch Detector — Scan {scan_id}")
|
||||||
|
print(f"[*] Target: {args.url}")
|
||||||
|
|
||||||
|
# Generate camera angles
|
||||||
|
angles = generate_scan_angles(args.angles)
|
||||||
|
print(f"[*] Capturing {len(angles)} screenshots...")
|
||||||
|
|
||||||
|
# Capture screenshots
|
||||||
|
screenshots_dir = Path(f"/tmp/matrix_glitch_{scan_id}")
|
||||||
|
screenshots = capture_screenshots(args.url, angles, screenshots_dir)
|
||||||
|
print(f"[*] Captured {len(screenshots)} screenshots")
|
||||||
|
|
||||||
|
# Filter patterns by severity and type
|
||||||
|
min_sev = GlitchSeverity(args.min_severity)
|
||||||
|
patterns = get_patterns_by_severity(min_sev)
|
||||||
|
if args.threejs:
|
||||||
|
threejs_patterns = get_threejs_patterns()
|
||||||
|
patterns = [p for p in patterns if p in threejs_patterns]
|
||||||
|
print(f"[*] Three.js-focused mode: {len(patterns)} patterns")
|
||||||
|
|
||||||
|
# Analyze with vision AI
|
||||||
|
print(f"[*] Analyzing with vision AI ({len(patterns)} patterns)...")
|
||||||
|
glitches = analyze_with_vision(screenshots, angles, patterns)
|
||||||
|
|
||||||
|
# Build and save report
|
||||||
|
report = build_report(args.url, angles, screenshots, glitches)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(report.to_json())
|
||||||
|
print(f"[*] Report saved: {args.output}")
|
||||||
|
else:
|
||||||
|
print(report.to_json())
|
||||||
|
|
||||||
|
print(f"\n[*] Done — {len(glitches)} glitches detected")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -19,25 +19,25 @@ PASS=0
|
|||||||
FAIL=0
|
FAIL=0
|
||||||
WARN=0
|
WARN=0
|
||||||
|
|
||||||
check_anthropic_model() {
|
check_kimi_model() {
|
||||||
local model="$1"
|
local model="$1"
|
||||||
local label="$2"
|
local label="$2"
|
||||||
local api_key="${ANTHROPIC_API_KEY:-}"
|
local api_key="${KIMI_API_KEY:-}"
|
||||||
|
|
||||||
if [ -z "$api_key" ]; then
|
if [ -z "$api_key" ]; then
|
||||||
# Try loading from .env
|
# Try loading from .env
|
||||||
api_key=$(grep '^ANTHROPIC_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
api_key=$(grep '^KIMI_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$api_key" ]; then
|
if [ -z "$api_key" ]; then
|
||||||
log "SKIP [$label] $model -- no ANTHROPIC_API_KEY"
|
log "SKIP [$label] $model -- no KIMI_API_KEY"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
response=$(curl -sf --max-time 10 -X POST \
|
response=$(curl -sf --max-time 10 -X POST \
|
||||||
"https://api.anthropic.com/v1/messages" \
|
"https://api.kimi.com/coding/v1/chat/completions" \
|
||||||
-H "x-api-key: ${api_key}" \
|
-H "x-api-key: ${api_key}" \
|
||||||
-H "anthropic-version: 2023-06-01" \
|
-H "x-api-provider: kimi-coding" \
|
||||||
-H "content-type: application/json" \
|
-H "content-type: application/json" \
|
||||||
-d "{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>&1 || echo "ERROR")
|
-d "{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>&1 || echo "ERROR")
|
||||||
|
|
||||||
@@ -85,26 +85,26 @@ else:
|
|||||||
print('')
|
print('')
|
||||||
" 2>/dev/null || echo "")
|
" 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [ -n "$primary" ] && [ "$provider" = "anthropic" ]; then
|
if [ -n "$primary" ] && [ "$provider" = "kimi-coding" ]; then
|
||||||
if check_anthropic_model "$primary" "PRIMARY"; then
|
if check_kimi_model "$primary" "PRIMARY"; then
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 1 ]; then
|
if [ "$rc" -eq 1 ]; then
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
log "CRITICAL: Primary model $primary is DEAD. Loops will fail."
|
log "CRITICAL: Primary model $primary is DEAD. Loops will fail."
|
||||||
log "Known good alternatives: claude-opus-4.6, claude-haiku-4-5-20251001"
|
log "Known good alternatives: kimi-k2.5, google/gemini-2.5-pro"
|
||||||
else
|
else
|
||||||
WARN=$((WARN + 1))
|
WARN=$((WARN + 1))
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
elif [ -n "$primary" ]; then
|
elif [ -n "$primary" ]; then
|
||||||
log "SKIP [PRIMARY] $primary -- non-anthropic provider ($provider), no validator yet"
|
log "SKIP [PRIMARY] $primary -- non-kimi provider ($provider), no validator yet"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cron model check (haiku)
|
# Cron model check (haiku)
|
||||||
CRON_MODEL="claude-haiku-4-5-20251001"
|
CRON_MODEL="kimi-k2.5"
|
||||||
if check_anthropic_model "$CRON_MODEL" "CRON"; then
|
if check_kimi_model "$CRON_MODEL" "CRON"; then
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Full Nostr agent-to-agent communication demo - FINAL WORKING
|
Full Nostr agent-to-agent communication demo - FINAL WORKING
|
||||||
"""
|
"""
|
||||||
|
|||||||
514
bin/pane-watchdog.sh
Executable file
514
bin/pane-watchdog.sh
Executable file
@@ -0,0 +1,514 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pane-watchdog.sh — Detect stuck/dead tmux panes and auto-restart them
|
||||||
|
#
|
||||||
|
# Tracks output hash per pane across cycles. If a pane's captured output
|
||||||
|
# hasn't changed for STUCK_CYCLES consecutive checks, the pane is STUCK.
|
||||||
|
# Dead panes (PID gone) are also detected.
|
||||||
|
#
|
||||||
|
# On STUCK/DEAD:
|
||||||
|
# 1. Kill the pane
|
||||||
|
# 2. Attempt restart with --resume (session ID from manifest)
|
||||||
|
# 3. Fallback: fresh prompt with last known task from logs
|
||||||
|
#
|
||||||
|
# State file: ~/.hermes/pane-state.json
|
||||||
|
# Log: ~/.hermes/logs/pane-watchdog.log
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# pane-watchdog.sh # One-shot check all sessions
|
||||||
|
# pane-watchdog.sh --daemon # Run every CHECK_INTERVAL seconds
|
||||||
|
# pane-watchdog.sh --status # Print current pane state
|
||||||
|
# pane-watchdog.sh --session NAME # Check only one session
|
||||||
|
#
|
||||||
|
# Issue: timmy-config #515
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
# === CONFIG ===
|
||||||
|
STATE_FILE="${PANE_STATE_FILE:-$HOME/.hermes/pane-state.json}"
|
||||||
|
LOG_FILE="${PANE_WATCHDOG_LOG:-$HOME/.hermes/logs/pane-watchdog.log}"
|
||||||
|
CHECK_INTERVAL="${PANE_CHECK_INTERVAL:-120}" # seconds between cycles
|
||||||
|
STUCK_CYCLES=2 # unchanged cycles before STUCK
|
||||||
|
MAX_RESTART_ATTEMPTS=3 # per pane per hour
|
||||||
|
RESTART_COOLDOWN=3600 # seconds between escalation alerts
|
||||||
|
CAPTURE_LINES=40 # lines of output to hash
|
||||||
|
|
||||||
|
# Sessions to monitor (all if empty)
|
||||||
|
MONITOR_SESSIONS="${PANE_WATCHDOG_SESSIONS:-}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# === HELPERS ===
|
||||||
|
|
||||||
|
# Capture last N lines of pane output and hash them
|
||||||
|
capture_pane_hash() {
|
||||||
|
local target="$1"
|
||||||
|
local output
|
||||||
|
output=$(tmux capture-pane -t "$target" -p -S "-${CAPTURE_LINES}" 2>/dev/null || echo "DEAD")
|
||||||
|
echo -n "$output" | shasum -a 256 | cut -d' ' -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if pane PID is alive
|
||||||
|
pane_pid_alive() {
|
||||||
|
local target="$1"
|
||||||
|
local pid
|
||||||
|
pid=$(tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null | head -1 || echo "")
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
return 1 # pane doesn't exist
|
||||||
|
fi
|
||||||
|
kill -0 "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get pane start command
|
||||||
|
pane_start_command() {
|
||||||
|
local target="$1"
|
||||||
|
tmux list-panes -t "$target" -F '#{pane_start_command}' 2>/dev/null | head -1 || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the pane's current running command (child process)
|
||||||
|
pane_current_command() {
|
||||||
|
local target="$1"
|
||||||
|
tmux list-panes -t "$target" -F '#{pane_current_command}' 2>/dev/null || echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only restart panes running hermes/agent commands (not zsh, python3 repls, etc.)
|
||||||
|
is_restartable() {
|
||||||
|
local cmd="$1"
|
||||||
|
case "$cmd" in
|
||||||
|
hermes|*hermes*|*agent*|*timmy*|*kimi*|*claude-loop*|*gemini-loop*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get session ID from hermes manifest if available
|
||||||
|
get_hermes_session_id() {
|
||||||
|
local session_name="$1"
|
||||||
|
local manifest="$HOME/.hermes/sessions/${session_name}/manifest.json"
|
||||||
|
if [ -f "$manifest" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
m = json.load(open('$manifest'))
|
||||||
|
print(m.get('session_id', m.get('id', '')))
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null || echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get last task from pane logs
|
||||||
|
get_last_task() {
|
||||||
|
local session_name="$1"
|
||||||
|
local log_dir="$HOME/.hermes/logs"
|
||||||
|
# Find the most recent log for this session
|
||||||
|
local log_file
|
||||||
|
log_file=$(find "$log_dir" -name "*${session_name}*" -type f -mtime -1 2>/dev/null | sort -r | head -1)
|
||||||
|
if [ -n "$log_file" ] && [ -f "$log_file" ]; then
|
||||||
|
# Extract last user prompt or task description
|
||||||
|
grep -i "task:\|prompt:\|issue\|working on" "$log_file" 2>/dev/null | tail -1 | sed 's/.*[:>] *//' | head -c 200
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart a pane with a fresh shell/command
|
||||||
|
restart_pane() {
|
||||||
|
local target="$1"
|
||||||
|
local session_name="${target%%:*}"
|
||||||
|
local session_id last_task cmd
|
||||||
|
|
||||||
|
log "RESTART: Attempting to restart $target"
|
||||||
|
|
||||||
|
# Kill existing pane
|
||||||
|
tmux kill-pane -t "$target" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Try --resume with session ID
|
||||||
|
session_id=$(get_hermes_session_id "$session_name")
|
||||||
|
if [ -n "$session_id" ]; then
|
||||||
|
log "RESTART: Trying --resume with session $session_id"
|
||||||
|
tmux split-window -t "$session_name" -d \
|
||||||
|
"hermes chat --resume '$session_id' 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||||
|
sleep 2
|
||||||
|
if pane_pid_alive "${session_name}:1" 2>/dev/null; then
|
||||||
|
log "RESTART: Success with --resume"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: fresh prompt
|
||||||
|
last_task=$(get_last_task "$session_name")
|
||||||
|
if [ -n "$last_task" ]; then
|
||||||
|
log "RESTART: Fallback — fresh prompt with task: $last_task"
|
||||||
|
tmux split-window -t "$session_name" -d \
|
||||||
|
"echo 'Watchdog restart — last task: $last_task' && hermes chat 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||||
|
else
|
||||||
|
log "RESTART: Fallback — fresh hermes chat"
|
||||||
|
tmux split-window -t "$session_name" -d \
|
||||||
|
"hermes chat 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
if pane_pid_alive "${session_name}:1" 2>/dev/null; then
|
||||||
|
log "RESTART: Fallback restart succeeded"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "RESTART: FAILED to restart $target"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# === STATE MANAGEMENT ===
|
||||||
|
|
||||||
|
read_state() {
|
||||||
|
if [ -f "$STATE_FILE" ]; then
|
||||||
|
cat "$STATE_FILE"
|
||||||
|
else
|
||||||
|
echo "{}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_state() {
|
||||||
|
echo "$1" > "$STATE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update state for a single pane and return JSON status
|
||||||
|
update_pane_state() {
|
||||||
|
local target="$1"
|
||||||
|
local hash="$2"
|
||||||
|
local is_alive="$3"
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" "$target" "$hash" "$is_alive" "$now" "$STUCK_CYCLES" <<'PYEOF'
|
||||||
|
import json, sys, time
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
target = sys.argv[2]
|
||||||
|
new_hash = sys.argv[3]
|
||||||
|
is_alive = sys.argv[4] == "true"
|
||||||
|
now = int(sys.argv[5])
|
||||||
|
stuck_cycles = int(sys.argv[6])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
pane = state.get(target, {
|
||||||
|
"hash": "",
|
||||||
|
"same_count": 0,
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"last_change": 0,
|
||||||
|
"last_check": 0,
|
||||||
|
"restart_attempts": 0,
|
||||||
|
"last_restart": 0,
|
||||||
|
"current_command": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not is_alive:
|
||||||
|
pane["status"] = "DEAD"
|
||||||
|
pane["same_count"] = 0
|
||||||
|
elif new_hash == pane.get("hash", ""):
|
||||||
|
pane["same_count"] = pane.get("same_count", 0) + 1
|
||||||
|
if pane["same_count"] >= stuck_cycles:
|
||||||
|
pane["status"] = "STUCK"
|
||||||
|
else:
|
||||||
|
pane["status"] = "STALE" if pane["same_count"] > 0 else "OK"
|
||||||
|
else:
|
||||||
|
pane["hash"] = new_hash
|
||||||
|
pane["same_count"] = 0
|
||||||
|
pane["status"] = "OK"
|
||||||
|
pane["last_change"] = now
|
||||||
|
|
||||||
|
pane["last_check"] = now
|
||||||
|
state[target] = pane
|
||||||
|
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
print(json.dumps(pane))
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reset restart attempt counter if cooldown expired
|
||||||
|
maybe_reset_restarts() {
|
||||||
|
local target="$1"
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" "$target" "$now" "$RESTART_COOLDOWN" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
target = sys.argv[2]
|
||||||
|
now = int(sys.argv[3])
|
||||||
|
cooldown = int(sys.argv[4])
|
||||||
|
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
pane = state.get(target, {})
|
||||||
|
last_restart = pane.get("last_restart", 0)
|
||||||
|
|
||||||
|
if now - last_restart > cooldown:
|
||||||
|
pane["restart_attempts"] = 0
|
||||||
|
|
||||||
|
state[target] = pane
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
print(pane.get("restart_attempts", 0))
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
increment_restart_attempt() {
|
||||||
|
local target="$1"
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" "$target" "$now" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
target = sys.argv[2]
|
||||||
|
now = int(sys.argv[3])
|
||||||
|
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
pane = state.get(target, {})
|
||||||
|
pane["restart_attempts"] = pane.get("restart_attempts", 0) + 1
|
||||||
|
pane["last_restart"] = now
|
||||||
|
pane["status"] = "RESTARTING"
|
||||||
|
|
||||||
|
state[target] = pane
|
||||||
|
with open(state_file, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
print(pane["restart_attempts"])
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# === CORE CHECK ===
|
||||||
|
|
||||||
|
check_pane() {
|
||||||
|
local target="$1"
|
||||||
|
local hash is_alive status current_cmd
|
||||||
|
|
||||||
|
# Capture state
|
||||||
|
hash=$(capture_pane_hash "$target")
|
||||||
|
if pane_pid_alive "$target"; then
|
||||||
|
is_alive="true"
|
||||||
|
else
|
||||||
|
is_alive="false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current command for the pane
|
||||||
|
current_cmd=$(pane_current_command "$target")
|
||||||
|
|
||||||
|
# Update state and get result
|
||||||
|
local result
|
||||||
|
result=$(update_pane_state "$target" "$hash" "$is_alive")
|
||||||
|
status=$(echo "$result" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('status','UNKNOWN'))" 2>/dev/null || echo "UNKNOWN")
|
||||||
|
|
||||||
|
case "$status" in
|
||||||
|
OK)
|
||||||
|
# Healthy, do nothing
|
||||||
|
;;
|
||||||
|
DEAD)
|
||||||
|
log "DETECTED: $target is DEAD (PID gone) cmd=$current_cmd"
|
||||||
|
if is_restartable "$current_cmd"; then
|
||||||
|
handle_stuck "$target"
|
||||||
|
else
|
||||||
|
log "SKIP: $target not a hermes pane (cmd=$current_cmd), not restarting"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
STUCK)
|
||||||
|
log "DETECTED: $target is STUCK (output unchanged for ${STUCK_CYCLES} cycles) cmd=$current_cmd"
|
||||||
|
if is_restartable "$current_cmd"; then
|
||||||
|
handle_stuck "$target"
|
||||||
|
else
|
||||||
|
log "SKIP: $target not a hermes pane (cmd=$current_cmd), not restarting"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
STALE)
|
||||||
|
# Output unchanged but within threshold — just log
|
||||||
|
local count
|
||||||
|
count=$(echo "$result" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('same_count',0))" 2>/dev/null || echo "?")
|
||||||
|
log "STALE: $target unchanged for $count cycle(s)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_stuck() {
|
||||||
|
local target="$1"
|
||||||
|
local session_name="${target%%:*}"
|
||||||
|
local attempts
|
||||||
|
|
||||||
|
# Check restart budget
|
||||||
|
attempts=$(maybe_reset_restarts "$target")
|
||||||
|
if [ "$attempts" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||||
|
log "ESCALATION: $target stuck ${attempts}x — manual intervention needed"
|
||||||
|
echo "ALERT: $target stuck after $attempts restart attempts" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempts=$(increment_restart_attempt "$target")
|
||||||
|
log "ACTION: Restarting $target (attempt $attempts/$MAX_RESTART_ATTEMPTS)"
|
||||||
|
|
||||||
|
if restart_pane "$target"; then
|
||||||
|
log "OK: $target restarted successfully"
|
||||||
|
else
|
||||||
|
log "FAIL: $target restart failed (attempt $attempts)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_all_sessions() {
|
||||||
|
local sessions
|
||||||
|
|
||||||
|
if [ -n "$MONITOR_SESSIONS" ]; then
|
||||||
|
IFS=',' read -ra sessions <<< "$MONITOR_SESSIONS"
|
||||||
|
else
|
||||||
|
sessions=()
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] && sessions+=("$line")
|
||||||
|
done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
local total=0 stuck=0 dead=0 ok=0
|
||||||
|
for session in "${sessions[@]}"; do
|
||||||
|
[ -z "$session" ] && continue
|
||||||
|
# Get pane targets
|
||||||
|
local panes
|
||||||
|
panes=$(tmux list-panes -t "$session" -F "${session}:#{window_index}.#{pane_index}" 2>/dev/null || true)
|
||||||
|
for target in $panes; do
|
||||||
|
check_pane "$target"
|
||||||
|
total=$((total + 1))
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
log "CHECK: Processed $total panes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# === STATUS DISPLAY ===
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
if [ ! -f "$STATE_FILE" ]; then
|
||||||
|
echo "No pane state file found at $STATE_FILE"
|
||||||
|
echo "Run pane-watchdog.sh once to initialize."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$STATE_FILE" <<'PYEOF'
|
||||||
|
import json, sys, time
|
||||||
|
|
||||||
|
state_file = sys.argv[1]
|
||||||
|
try:
|
||||||
|
with open(state_file) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
print("No state data yet.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
print("No panes tracked.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
print(f"{'PANE':<35} {'STATUS':<12} {'STALE':<6} {'LAST CHANGE':<15} {'RESTARTS'}")
|
||||||
|
print("-" * 90)
|
||||||
|
|
||||||
|
for target in sorted(state.keys()):
|
||||||
|
p = state[target]
|
||||||
|
status = p.get("status", "?")
|
||||||
|
same = p.get("same_count", 0)
|
||||||
|
last_change = p.get("last_change", 0)
|
||||||
|
restarts = p.get("restart_attempts", 0)
|
||||||
|
|
||||||
|
if last_change:
|
||||||
|
ago = now - last_change
|
||||||
|
if ago < 60:
|
||||||
|
change_str = f"{ago}s ago"
|
||||||
|
elif ago < 3600:
|
||||||
|
change_str = f"{ago//60}m ago"
|
||||||
|
else:
|
||||||
|
change_str = f"{ago//3600}h ago"
|
||||||
|
else:
|
||||||
|
change_str = "never"
|
||||||
|
|
||||||
|
# Color code
|
||||||
|
if status == "OK":
|
||||||
|
icon = "✓"
|
||||||
|
elif status == "STUCK":
|
||||||
|
icon = "✖"
|
||||||
|
elif status == "DEAD":
|
||||||
|
icon = "☠"
|
||||||
|
elif status == "STALE":
|
||||||
|
icon = "⏳"
|
||||||
|
else:
|
||||||
|
icon = "?"
|
||||||
|
|
||||||
|
print(f" {icon} {target:<32} {status:<12} {same:<6} {change_str:<15} {restarts}")
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# === DAEMON MODE ===
|
||||||
|
|
||||||
|
run_daemon() {
|
||||||
|
log "DAEMON: Starting (interval=${CHECK_INTERVAL}s, stuck_threshold=${STUCK_CYCLES})"
|
||||||
|
echo "Pane watchdog started. Checking every ${CHECK_INTERVAL}s. Ctrl+C to stop."
|
||||||
|
echo "Log: $LOG_FILE"
|
||||||
|
echo "State: $STATE_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
check_all_sessions
|
||||||
|
sleep "$CHECK_INTERVAL"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# === MAIN ===
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--daemon)
|
||||||
|
run_daemon
|
||||||
|
;;
|
||||||
|
--status)
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
--session)
|
||||||
|
if [ -z "${2:-}" ]; then
|
||||||
|
echo "Usage: pane-watchdog.sh --session SESSION_NAME"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MONITOR_SESSIONS="$2"
|
||||||
|
check_all_sessions
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "pane-watchdog.sh — Detect stuck/dead tmux panes and auto-restart"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " pane-watchdog.sh # One-shot check"
|
||||||
|
echo " pane-watchdog.sh --daemon # Continuous monitoring"
|
||||||
|
echo " pane-watchdog.sh --status # Show pane state"
|
||||||
|
echo " pane-watchdog.sh --session S # Check one session"
|
||||||
|
echo ""
|
||||||
|
echo "Config (env vars):"
|
||||||
|
echo " PANE_CHECK_INTERVAL Seconds between checks (default: 120)"
|
||||||
|
echo " PANE_WATCHDOG_SESSIONS Comma-separated session names"
|
||||||
|
echo " PANE_STATE_FILE State file path"
|
||||||
|
echo " STUCK_CYCLES Unchanged cycles before STUCK (default: 2)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
check_all_sessions
|
||||||
|
;;
|
||||||
|
esac
|
||||||
271
bin/preflight-provider-check.py
Normal file
271
bin/preflight-provider-check.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pre-Flight Provider Check Script
|
||||||
|
Issue #508: [Robustness] Credential drain detection — provider health checks
|
||||||
|
|
||||||
|
Pre-flight check before session launch: verifies provider credentials and balance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 preflight-provider-check.py # Check all providers
|
||||||
|
python3 preflight-provider-check.py --launch # Check and return exit code
|
||||||
|
python3 preflight-provider-check.py --balance # Check OpenRouter balance
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, yaml, urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
|
LOG_DIR = Path.home() / ".local" / "timmy" / "fleet-health"
|
||||||
|
LOG_FILE = LOG_DIR / "preflight-check.log"
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
"""Log message to file and optionally console."""
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
log_entry = "[" + timestamp + "] " + msg
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOG_FILE, "a") as f:
|
||||||
|
f.write(log_entry + "\n")
|
||||||
|
|
||||||
|
if "--quiet" not in sys.argv:
|
||||||
|
print(log_entry)
|
||||||
|
|
||||||
|
def get_provider_api_key(provider):
|
||||||
|
"""Get API key for a provider from .env or environment."""
|
||||||
|
env_file = HERMES_HOME / ".env"
|
||||||
|
if env_file.exists():
|
||||||
|
with open(env_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith(provider.upper() + "_API_KEY="):
|
||||||
|
return line.split("=", 1)[1].strip().strip("'\"")
|
||||||
|
|
||||||
|
return os.environ.get(provider.upper() + "_API_KEY")
|
||||||
|
|
||||||
|
def check_openrouter_balance(api_key):
|
||||||
|
"""Check OpenRouter balance via /api/v1/auth/key."""
|
||||||
|
if not api_key:
|
||||||
|
return False, "No API key", 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://openrouter.ai/api/v1/auth/key",
|
||||||
|
headers={"Authorization": "Bearer " + api_key}
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
|
||||||
|
# Check for credits
|
||||||
|
credits = data.get("data", {}).get("limit", 0)
|
||||||
|
usage = data.get("data", {}).get("usage", 0)
|
||||||
|
remaining = credits - usage if credits else None
|
||||||
|
|
||||||
|
if remaining is not None and remaining <= 0:
|
||||||
|
return False, "No credits remaining", 0
|
||||||
|
elif remaining is not None:
|
||||||
|
return True, "Credits available", remaining
|
||||||
|
else:
|
||||||
|
return True, "Unlimited or unknown balance", None
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
return False, "Invalid API key", 0
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(e.code), 0
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:100], 0
|
||||||
|
|
||||||
|
def check_nous_key(api_key):
|
||||||
|
"""Check Nous API key with minimal test call."""
|
||||||
|
if not api_key:
|
||||||
|
return False, "No API key"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://inference.nousresearch.com/v1/models",
|
||||||
|
headers={"Authorization": "Bearer " + api_key}
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
|
||||||
|
if resp.status == 200:
|
||||||
|
return True, "Valid key"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(resp.status)
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
return False, "Invalid API key"
|
||||||
|
elif e.code == 403:
|
||||||
|
return False, "Forbidden"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(e.code)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:100]
|
||||||
|
|
||||||
|
def check_anthropic_key(api_key):
|
||||||
|
"""Check Anthropic API key with minimal test call."""
|
||||||
|
if not api_key:
|
||||||
|
return False, "No API key"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.anthropic.com/v1/models",
|
||||||
|
headers={
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
|
||||||
|
if resp.status == 200:
|
||||||
|
return True, "Valid key"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(resp.status)
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
return False, "Invalid API key"
|
||||||
|
elif e.code == 403:
|
||||||
|
return False, "Forbidden"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(e.code)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:100]
|
||||||
|
|
||||||
|
def check_ollama():
|
||||||
|
"""Check if Ollama is running."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request("http://localhost:11434/api/tags")
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
|
||||||
|
if resp.status == 200:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
models = data.get("models", [])
|
||||||
|
return True, str(len(models)) + " models loaded"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(resp.status)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:100]
|
||||||
|
|
||||||
|
def get_configured_provider():
|
||||||
|
"""Get the configured provider from global config."""
|
||||||
|
config_file = HERMES_HOME / "config.yaml"
|
||||||
|
if not config_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
model_config = config.get("model", {})
|
||||||
|
if isinstance(model_config, dict):
|
||||||
|
return model_config.get("provider")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run_preflight_check():
|
||||||
|
"""Run pre-flight check on all providers."""
|
||||||
|
log("=== Pre-Flight Provider Check ===")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# Check OpenRouter
|
||||||
|
or_key = get_provider_api_key("openrouter")
|
||||||
|
or_ok, or_msg, or_balance = check_openrouter_balance(or_key)
|
||||||
|
results["openrouter"] = {"healthy": or_ok, "message": or_msg, "balance": or_balance}
|
||||||
|
|
||||||
|
# Check Nous
|
||||||
|
nous_key = get_provider_api_key("nous")
|
||||||
|
nous_ok, nous_msg = check_nous_key(nous_key)
|
||||||
|
results["nous"] = {"healthy": nous_ok, "message": nous_msg}
|
||||||
|
|
||||||
|
# Check Anthropic
|
||||||
|
anthropic_key = get_provider_api_key("anthropic")
|
||||||
|
anthropic_ok, anthropic_msg = check_anthropic_key(anthropic_key)
|
||||||
|
results["anthropic"] = {"healthy": anthropic_ok, "message": anthropic_msg}
|
||||||
|
|
||||||
|
# Check Ollama
|
||||||
|
ollama_ok, ollama_msg = check_ollama()
|
||||||
|
results["ollama"] = {"healthy": ollama_ok, "message": ollama_msg}
|
||||||
|
|
||||||
|
# Get configured provider
|
||||||
|
configured = get_configured_provider()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
healthy_count = sum(1 for r in results.values() if r["healthy"])
|
||||||
|
total_count = len(results)
|
||||||
|
|
||||||
|
log("Results: " + str(healthy_count) + "/" + str(total_count) + " providers healthy")
|
||||||
|
|
||||||
|
for provider, result in results.items():
|
||||||
|
status = "HEALTHY" if result["healthy"] else "UNHEALTHY"
|
||||||
|
extra = ""
|
||||||
|
if provider == "openrouter" and result.get("balance") is not None:
|
||||||
|
extra = " (balance: " + str(result["balance"]) + ")"
|
||||||
|
|
||||||
|
log(" " + provider + ": " + status + " - " + result["message"] + extra)
|
||||||
|
|
||||||
|
if configured:
|
||||||
|
log("Configured provider: " + configured)
|
||||||
|
if configured in results and not results[configured]["healthy"]:
|
||||||
|
log("WARNING: Configured provider " + configured + " is UNHEALTHY!")
|
||||||
|
|
||||||
|
return results, configured
|
||||||
|
|
||||||
|
def check_launch_readiness():
|
||||||
|
"""Check if we're ready to launch sessions."""
|
||||||
|
results, configured = run_preflight_check()
|
||||||
|
|
||||||
|
# Check if configured provider is healthy
|
||||||
|
if configured and configured in results:
|
||||||
|
if not results[configured]["healthy"]:
|
||||||
|
log("LAUNCH BLOCKED: Configured provider " + configured + " is unhealthy")
|
||||||
|
return False, configured + " is unhealthy"
|
||||||
|
|
||||||
|
# Check if at least one provider is healthy
|
||||||
|
healthy_providers = [p for p, r in results.items() if r["healthy"]]
|
||||||
|
if not healthy_providers:
|
||||||
|
log("LAUNCH BLOCKED: No healthy providers available")
|
||||||
|
return False, "No healthy providers"
|
||||||
|
|
||||||
|
log("LAUNCH READY: " + str(len(healthy_providers)) + " healthy providers available")
|
||||||
|
return True, "Ready"
|
||||||
|
|
||||||
|
def show_balance():
|
||||||
|
"""Show OpenRouter balance."""
|
||||||
|
api_key = get_provider_api_key("openrouter")
|
||||||
|
if not api_key:
|
||||||
|
print("No OpenRouter API key found")
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, msg, balance = check_openrouter_balance(api_key)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
if balance is not None:
|
||||||
|
print("OpenRouter balance: " + str(balance) + " credits")
|
||||||
|
else:
|
||||||
|
print("OpenRouter: " + msg)
|
||||||
|
else:
|
||||||
|
print("OpenRouter: " + msg)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if "--balance" in sys.argv:
|
||||||
|
show_balance()
|
||||||
|
elif "--launch" in sys.argv:
|
||||||
|
ready, message = check_launch_readiness()
|
||||||
|
if ready:
|
||||||
|
print("READY")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("BLOCKED: " + message)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
run_preflight_check()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
411
bin/provider-health-monitor.py
Normal file
411
bin/provider-health-monitor.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Provider Health Monitor Script
|
||||||
|
Issue #509: [Robustness] Provider-aware profile config — auto-switch on failure
|
||||||
|
|
||||||
|
Monitors provider health and automatically switches profiles to working providers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 provider-health-monitor.py # Run once
|
||||||
|
python3 provider-health-monitor.py --daemon # Run continuously
|
||||||
|
python3 provider-health-monitor.py --status # Show provider health
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, yaml, urllib.request, time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
|
PROFILES_DIR = HERMES_HOME / "profiles"
|
||||||
|
LOG_DIR = Path.home() / ".local" / "timmy" / "fleet-health"
|
||||||
|
STATE_FILE = LOG_DIR / "tmux-state.json"
|
||||||
|
LOG_FILE = LOG_DIR / "provider-health.log"
|
||||||
|
|
||||||
|
# Provider test endpoints
|
||||||
|
PROVIDER_TESTS = {
|
||||||
|
"openrouter": {
|
||||||
|
"url": "https://openrouter.ai/api/v1/models",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": lambda api_key: {"Authorization": "Bearer " + api_key},
|
||||||
|
"timeout": 10
|
||||||
|
},
|
||||||
|
"anthropic": {
|
||||||
|
"url": "https://api.anthropic.com/v1/models",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": lambda api_key: {"x-api-key": api_key, "anthropic-version": "2023-06-01"},
|
||||||
|
"timeout": 10
|
||||||
|
},
|
||||||
|
"nous": {
|
||||||
|
"url": "https://inference.nousresearch.com/v1/models",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": lambda api_key: {"Authorization": "Bearer " + api_key},
|
||||||
|
"timeout": 10
|
||||||
|
},
|
||||||
|
"kimi-coding": {
|
||||||
|
"url": "https://api.kimi.com/coding/v1/models",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": lambda api_key: {"x-api-key": api_key, "x-api-provider": "kimi-coding"},
|
||||||
|
"timeout": 10
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"url": "http://localhost:11434/api/tags",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": lambda api_key: {},
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
"""Log message to file and optionally console."""
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
log_entry = "[" + timestamp + "] " + msg
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOG_FILE, "a") as f:
|
||||||
|
f.write(log_entry + "\n")
|
||||||
|
|
||||||
|
if "--quiet" not in sys.argv:
|
||||||
|
print(log_entry)
|
||||||
|
|
||||||
|
def get_provider_api_key(provider):
|
||||||
|
"""Get API key for a provider from .env or environment."""
|
||||||
|
env_file = HERMES_HOME / ".env"
|
||||||
|
if env_file.exists():
|
||||||
|
with open(env_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith(provider.upper() + "_API_KEY="):
|
||||||
|
return line.split("=", 1)[1].strip().strip("'\"")
|
||||||
|
|
||||||
|
return os.environ.get(provider.upper() + "_API_KEY")
|
||||||
|
|
||||||
|
def test_provider(provider, api_key=None):
|
||||||
|
"""Test if a provider is healthy."""
|
||||||
|
config = PROVIDER_TESTS.get(provider)
|
||||||
|
if not config:
|
||||||
|
return False, "Unknown provider: " + provider
|
||||||
|
|
||||||
|
headers = config["headers"](api_key or "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
config["url"],
|
||||||
|
headers=headers,
|
||||||
|
method=config["method"]
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=config["timeout"])
|
||||||
|
|
||||||
|
if resp.status == 200:
|
||||||
|
return True, "Healthy"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(resp.status)
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
return False, "Unauthorized (401)"
|
||||||
|
elif e.code == 403:
|
||||||
|
return False, "Forbidden (403)"
|
||||||
|
elif e.code == 429:
|
||||||
|
return True, "Rate limited but accessible"
|
||||||
|
else:
|
||||||
|
return False, "HTTP " + str(e.code)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:100]
|
||||||
|
|
||||||
|
def get_all_providers():
|
||||||
|
"""Get all providers from profiles and global config."""
|
||||||
|
providers = set()
|
||||||
|
|
||||||
|
# Global config
|
||||||
|
global_config = HERMES_HOME / "config.yaml"
|
||||||
|
if global_config.exists():
|
||||||
|
try:
|
||||||
|
with open(global_config) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Primary model provider
|
||||||
|
model_config = config.get("model", {})
|
||||||
|
if isinstance(model_config, dict):
|
||||||
|
provider = model_config.get("provider", "")
|
||||||
|
if provider:
|
||||||
|
providers.add(provider)
|
||||||
|
|
||||||
|
# Auxiliary providers
|
||||||
|
auxiliary = config.get("auxiliary", {})
|
||||||
|
for aux_config in auxiliary.values():
|
||||||
|
if isinstance(aux_config, dict):
|
||||||
|
provider = aux_config.get("provider", "")
|
||||||
|
if provider and provider != "auto":
|
||||||
|
providers.add(provider)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Profile configs
|
||||||
|
if PROFILES_DIR.exists():
|
||||||
|
for profile_dir in PROFILES_DIR.iterdir():
|
||||||
|
if profile_dir.is_dir():
|
||||||
|
config_file = profile_dir / "config.yaml"
|
||||||
|
if config_file.exists():
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
model_config = config.get("model", {})
|
||||||
|
if isinstance(model_config, dict):
|
||||||
|
provider = model_config.get("provider", "")
|
||||||
|
if provider:
|
||||||
|
providers.add(provider)
|
||||||
|
|
||||||
|
auxiliary = config.get("auxiliary", {})
|
||||||
|
for aux_config in auxiliary.values():
|
||||||
|
if isinstance(aux_config, dict):
|
||||||
|
provider = aux_config.get("provider", "")
|
||||||
|
if provider and provider != "auto":
|
||||||
|
providers.add(provider)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add common providers even if not configured
|
||||||
|
providers.update(["openrouter", "nous", "ollama"])
|
||||||
|
|
||||||
|
return list(providers)
|
||||||
|
|
||||||
|
def build_health_map():
|
||||||
|
"""Build a health map of all providers."""
|
||||||
|
providers = get_all_providers()
|
||||||
|
health_map = {}
|
||||||
|
|
||||||
|
log("Testing " + str(len(providers)) + " providers...")
|
||||||
|
|
||||||
|
for provider in providers:
|
||||||
|
api_key = get_provider_api_key(provider)
|
||||||
|
healthy, message = test_provider(provider, api_key)
|
||||||
|
|
||||||
|
health_map[provider] = {
|
||||||
|
"healthy": healthy,
|
||||||
|
"message": message,
|
||||||
|
"last_test": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"api_key_present": bool(api_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
status = "HEALTHY" if healthy else "UNHEALTHY"
|
||||||
|
log(" " + provider + ": " + status + " - " + message)
|
||||||
|
|
||||||
|
return health_map
|
||||||
|
|
||||||
|
def get_fallback_providers(health_map):
|
||||||
|
"""Get list of healthy providers in priority order."""
|
||||||
|
# Priority order: nous, openrouter, ollama, others
|
||||||
|
priority_order = ["nous", "openrouter", "ollama", "anthropic", "kimi-coding"]
|
||||||
|
|
||||||
|
healthy = []
|
||||||
|
for provider in priority_order:
|
||||||
|
if provider in health_map and health_map[provider]["healthy"]:
|
||||||
|
healthy.append(provider)
|
||||||
|
|
||||||
|
# Add any other healthy providers not in priority list
|
||||||
|
for provider, info in health_map.items():
|
||||||
|
if info["healthy"] and provider not in healthy:
|
||||||
|
healthy.append(provider)
|
||||||
|
|
||||||
|
return healthy
|
||||||
|
|
||||||
|
def update_profile_config(profile_name, new_provider):
|
||||||
|
"""Update a profile's config to use a new provider."""
|
||||||
|
config_file = PROFILES_DIR / profile_name / "config.yaml"
|
||||||
|
|
||||||
|
if not config_file.exists():
|
||||||
|
return False, "Config file not found"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Update model provider
|
||||||
|
if "model" not in config:
|
||||||
|
config["model"] = {}
|
||||||
|
|
||||||
|
old_provider = config["model"].get("provider", "unknown")
|
||||||
|
config["model"]["provider"] = new_provider
|
||||||
|
|
||||||
|
# Update auxiliary providers if they were using the old provider
|
||||||
|
auxiliary = config.get("auxiliary", {})
|
||||||
|
for aux_name, aux_config in auxiliary.items():
|
||||||
|
if isinstance(aux_config, dict) and aux_config.get("provider") == old_provider:
|
||||||
|
aux_config["provider"] = new_provider
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(config, f, default_flow_style=False)
|
||||||
|
|
||||||
|
log("Updated " + profile_name + ": " + old_provider + " -> " + new_provider)
|
||||||
|
return True, "Updated"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def check_profiles(health_map):
|
||||||
|
"""Check all profiles and update unhealthy providers."""
|
||||||
|
if not PROFILES_DIR.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback_providers = get_fallback_providers(health_map)
|
||||||
|
if not fallback_providers:
|
||||||
|
log("CRITICAL: No healthy providers available!")
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_profiles = []
|
||||||
|
|
||||||
|
for profile_dir in PROFILES_DIR.iterdir():
|
||||||
|
if not profile_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
profile_name = profile_dir.name
|
||||||
|
config_file = profile_dir / "config.yaml"
|
||||||
|
|
||||||
|
if not config_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
model_config = config.get("model", {})
|
||||||
|
if not isinstance(model_config, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_provider = model_config.get("provider", "")
|
||||||
|
if not current_provider:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if current provider is healthy
|
||||||
|
if current_provider in health_map and health_map[current_provider]["healthy"]:
|
||||||
|
continue # Provider is healthy, no action needed
|
||||||
|
|
||||||
|
# Find best fallback
|
||||||
|
best_fallback = None
|
||||||
|
for provider in fallback_providers:
|
||||||
|
if provider != current_provider:
|
||||||
|
best_fallback = provider
|
||||||
|
break
|
||||||
|
|
||||||
|
if not best_fallback:
|
||||||
|
log("No fallback for " + profile_name + " (current: " + current_provider + ")")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update profile
|
||||||
|
success, message = update_profile_config(profile_name, best_fallback)
|
||||||
|
if success:
|
||||||
|
updated_profiles.append({
|
||||||
|
"profile": profile_name,
|
||||||
|
"old_provider": current_provider,
|
||||||
|
"new_provider": best_fallback
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log("Error processing " + profile_name + ": " + str(e))
|
||||||
|
|
||||||
|
return updated_profiles
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
"""Load state from tmux-state.json."""
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_state(state):
|
||||||
|
"""Save state to tmux-state.json."""
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
def run_once():
|
||||||
|
"""Run provider health check once."""
|
||||||
|
log("=== Provider Health Check ===")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
|
||||||
|
# Build health map
|
||||||
|
health_map = build_health_map()
|
||||||
|
|
||||||
|
# Check profiles and update if needed
|
||||||
|
updated_profiles = check_profiles(health_map)
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
state["provider_health"] = health_map
|
||||||
|
state["last_provider_check"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
if updated_profiles:
|
||||||
|
state["last_profile_updates"] = updated_profiles
|
||||||
|
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
healthy_count = sum(1 for p in health_map.values() if p["healthy"])
|
||||||
|
total_count = len(health_map)
|
||||||
|
|
||||||
|
log("Health: " + str(healthy_count) + "/" + str(total_count) + " providers healthy")
|
||||||
|
|
||||||
|
if updated_profiles:
|
||||||
|
log("Updated " + str(len(updated_profiles)) + " profiles:")
|
||||||
|
for update in updated_profiles:
|
||||||
|
log(" " + update["profile"] + ": " + update["old_provider"] + " -> " + update["new_provider"])
|
||||||
|
|
||||||
|
def show_status():
|
||||||
|
"""Show provider health status."""
|
||||||
|
state = load_state()
|
||||||
|
health_map = state.get("provider_health", {})
|
||||||
|
|
||||||
|
if not health_map:
|
||||||
|
print("No provider health data available. Run without --status first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Provider Health (last updated: " + str(state.get("last_provider_check", "unknown")) + ")")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
for provider, info in sorted(health_map.items()):
|
||||||
|
status = "HEALTHY" if info["healthy"] else "UNHEALTHY"
|
||||||
|
message = info.get("message", "")
|
||||||
|
api_key = "yes" if info.get("api_key_present") else "no"
|
||||||
|
|
||||||
|
print(provider.ljust(20) + " " + status.ljust(10) + " API key: " + api_key + " - " + message)
|
||||||
|
|
||||||
|
# Show recent updates
|
||||||
|
updates = state.get("last_profile_updates", [])
|
||||||
|
if updates:
|
||||||
|
print()
|
||||||
|
print("Recent Profile Updates:")
|
||||||
|
for update in updates:
|
||||||
|
print(" " + update["profile"] + ": " + update["old_provider"] + " -> " + update["new_provider"])
|
||||||
|
|
||||||
|
def daemon_mode():
|
||||||
|
"""Run continuously."""
|
||||||
|
log("Starting provider health daemon (check every 300s)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
run_once()
|
||||||
|
time.sleep(300) # Check every 5 minutes
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("Daemon stopped by user")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log("Error: " + str(e))
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if "--status" in sys.argv:
|
||||||
|
show_status()
|
||||||
|
elif "--daemon" in sys.argv:
|
||||||
|
daemon_mode()
|
||||||
|
else:
|
||||||
|
run_once()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Soul Eval Gate — The Conscience of the Training Pipeline
|
Soul Eval Gate — The Conscience of the Training Pipeline
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Uses Hermes CLI plus workforce-manager to triage and review.
|
# Uses Hermes CLI plus workforce-manager to triage and review.
|
||||||
# Timmy is the brain. Other agents are the hands.
|
# Timmy is the brain. Other agents are the hands.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail\n\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
LOG_DIR="$HOME/.hermes/logs"
|
LOG_DIR="$HOME/.hermes/logs"
|
||||||
LOG="$LOG_DIR/timmy-orchestrator.log"
|
LOG="$LOG_DIR/timmy-orchestrator.log"
|
||||||
@@ -40,6 +40,7 @@ gather_state() {
|
|||||||
> "$state_dir/unassigned.txt"
|
> "$state_dir/unassigned.txt"
|
||||||
> "$state_dir/open_prs.txt"
|
> "$state_dir/open_prs.txt"
|
||||||
> "$state_dir/agent_status.txt"
|
> "$state_dir/agent_status.txt"
|
||||||
|
> "$state_dir/uncommitted_work.txt"
|
||||||
|
|
||||||
for repo in $REPOS; do
|
for repo in $REPOS; do
|
||||||
local short=$(echo "$repo" | cut -d/ -f2)
|
local short=$(echo "$repo" | cut -d/ -f2)
|
||||||
@@ -71,6 +72,24 @@ for p in json.load(sys.stdin):
|
|||||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||||
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
||||||
|
|
||||||
|
# Scan worktrees for uncommitted work
|
||||||
|
for wt_dir in "$HOME/worktrees"/*/; do
|
||||||
|
[ -d "$wt_dir" ] || continue
|
||||||
|
[ -d "$wt_dir/.git" ] || continue
|
||||||
|
local dirty
|
||||||
|
dirty=$(cd "$wt_dir" && git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||||
|
if [ "${dirty:-0}" -gt 0 ]; then
|
||||||
|
local branch
|
||||||
|
branch=$(cd "$wt_dir" && git branch --show-current 2>/dev/null || echo "?")
|
||||||
|
local age=""
|
||||||
|
local last_commit
|
||||||
|
last_commit=$(cd "$wt_dir" && git log -1 --format=%ct 2>/dev/null || echo 0)
|
||||||
|
local now=$(date +%s)
|
||||||
|
local stale_mins=$(( (now - last_commit) / 60 ))
|
||||||
|
echo "DIR=$wt_dir BRANCH=$branch DIRTY=$dirty STALE=${stale_mins}m" >> "$state_dir/uncommitted_work.txt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "$state_dir"
|
echo "$state_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +100,25 @@ run_triage() {
|
|||||||
|
|
||||||
log "Cycle: $unassigned_count unassigned, $pr_count open PRs"
|
log "Cycle: $unassigned_count unassigned, $pr_count open PRs"
|
||||||
|
|
||||||
|
# Check for uncommitted work — nag if stale
|
||||||
|
local uncommitted_count
|
||||||
|
uncommitted_count=$(wc -l < "$state_dir/uncommitted_work.txt" 2>/dev/null | tr -d " " || echo 0)
|
||||||
|
if [ "${uncommitted_count:-0}" -gt 0 ]; then
|
||||||
|
log "WARNING: $uncommitted_count worktree(s) with uncommitted work"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
log " UNCOMMITTED: $line"
|
||||||
|
# Auto-commit stale work (>60 min without commit)
|
||||||
|
local stale=$(echo "$line" | sed 's/.*STALE=\([0-9]*\)m.*/\1/')
|
||||||
|
local wt_dir=$(echo "$line" | sed 's/.*DIR=\([^ ]*\) .*/\1/')
|
||||||
|
if [ "${stale:-0}" -gt 60 ]; then
|
||||||
|
log " AUTO-COMMITTING stale work in $wt_dir (${stale}m stale)"
|
||||||
|
(cd "$wt_dir" && git add -A && git commit -m "WIP: orchestrator auto-commit — ${stale}m stale work
|
||||||
|
|
||||||
|
Preserved by timmy-orchestrator to prevent loss." 2>/dev/null && git push 2>/dev/null) && log " COMMITTED: $wt_dir" || log " COMMIT FAILED: $wt_dir"
|
||||||
|
fi
|
||||||
|
done < "$state_dir/uncommitted_work.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
# If nothing to do, skip the LLM call
|
# If nothing to do, skip the LLM call
|
||||||
if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then
|
if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then
|
||||||
log "Nothing to triage"
|
log "Nothing to triage"
|
||||||
@@ -198,6 +236,12 @@ FOOTER
|
|||||||
log "=== Timmy Orchestrator Started (PID $$) ==="
|
log "=== Timmy Orchestrator Started (PID $$) ==="
|
||||||
log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI"
|
log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI"
|
||||||
|
|
||||||
|
# Start auto-commit-guard daemon for work preservation
|
||||||
|
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||||||
|
nohup bash "$SCRIPT_DIR/auto-commit-guard.sh" 120 >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||||||
|
log "Started auto-commit-guard daemon (PID $!)"
|
||||||
|
fi
|
||||||
|
|
||||||
WORKFORCE_CYCLE=0
|
WORKFORCE_CYCLE=0
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
|
|||||||
97
bin/tmux-resume.sh
Executable file
97
bin/tmux-resume.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ── tmux-resume.sh — Cold-start Session Resume ───────────────────────────
|
||||||
|
# Reads ~/.timmy/tmux-state.json and resumes hermes sessions.
|
||||||
|
# Run at startup to restore pane state after supervisor restart.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||||
|
|
||||||
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
|
echo "[tmux-resume] No manifest found at $MANIFEST — starting fresh."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import json, subprocess, os, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||||
|
return r.stdout.strip(), r.returncode
|
||||||
|
except Exception as e:
|
||||||
|
return str(e), 1
|
||||||
|
|
||||||
|
def session_exists(name):
|
||||||
|
out, _ = run(f"tmux has-session -t '{name}' 2>&1")
|
||||||
|
return "can't find" not in out.lower()
|
||||||
|
|
||||||
|
with open(MANIFEST) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
ts = state.get("timestamp", "unknown")
|
||||||
|
age = "unknown"
|
||||||
|
try:
|
||||||
|
t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
delta = datetime.now(timezone.utc) - t
|
||||||
|
mins = int(delta.total_seconds() / 60)
|
||||||
|
if mins < 60:
|
||||||
|
age = f"{mins}m ago"
|
||||||
|
else:
|
||||||
|
age = f"{mins//60}h {mins%60}m ago"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[tmux-resume] Manifest from {age}: {state['summary']['total_sessions']} sessions, "
|
||||||
|
f"{state['summary']['hermes_panes']} hermes panes")
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for pane in state.get("panes", []):
|
||||||
|
if not pane.get("is_hermes"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
addr = pane["address"] # e.g. "BURN:2.3"
|
||||||
|
session = addr.split(":")[0]
|
||||||
|
session_id = pane.get("session_id")
|
||||||
|
profile = pane.get("profile", "default")
|
||||||
|
model = pane.get("model", "")
|
||||||
|
task = pane.get("task", "")
|
||||||
|
|
||||||
|
# Skip if session already exists (already running)
|
||||||
|
if session_exists(session):
|
||||||
|
print(f" [skip] {addr} — session '{session}' already exists")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Respawn hermes with session resume if we have a session ID
|
||||||
|
if session_id:
|
||||||
|
print(f" [resume] {addr} — profile={profile} model={model} session={session_id}")
|
||||||
|
cmd = f"hermes chat --resume {session_id}"
|
||||||
|
else:
|
||||||
|
print(f" [start] {addr} — profile={profile} model={model} (no session ID)")
|
||||||
|
cmd = f"hermes chat --profile {profile}"
|
||||||
|
|
||||||
|
# Create tmux session and run hermes
|
||||||
|
run(f"tmux new-session -d -s '{session}' -n '{session}:0'")
|
||||||
|
run(f"tmux send-keys -t '{session}' '{cmd}' Enter")
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
# Write resume log
|
||||||
|
log = {
|
||||||
|
"resumed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"manifest_age": age,
|
||||||
|
"restored": restored,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
log_path = os.path.expanduser("~/.timmy/tmux-resume.log")
|
||||||
|
with open(log_path, "w") as f:
|
||||||
|
json.dump(log, f, indent=2)
|
||||||
|
|
||||||
|
print(f"[tmux-resume] Done: {restored} restored, {skipped} skipped")
|
||||||
|
PYEOF
|
||||||
237
bin/tmux-state.sh
Executable file
237
bin/tmux-state.sh
Executable file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ── tmux-state.sh — Session State Persistence Manifest ───────────────────
|
||||||
|
# Snapshots all tmux pane state to ~/.timmy/tmux-state.json
|
||||||
|
# Run every supervisor cycle. Cold-start reads this manifest to resume.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||||
|
mkdir -p "$(dirname "$MANIFEST")"
|
||||||
|
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import json, subprocess, os, time, re, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
"""Run command, return stdout or empty string."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
||||||
|
return r.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_sessions():
|
||||||
|
"""Get all tmux sessions with metadata."""
|
||||||
|
raw = run("tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}|#{session_group}|#{session_id}'")
|
||||||
|
sessions = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split("|")
|
||||||
|
if len(parts) < 6:
|
||||||
|
continue
|
||||||
|
sessions.append({
|
||||||
|
"name": parts[0],
|
||||||
|
"windows": int(parts[1]),
|
||||||
|
"created_epoch": int(parts[2]),
|
||||||
|
"created": datetime.fromtimestamp(int(parts[2]), tz=timezone.utc).isoformat(),
|
||||||
|
"attached": parts[3] == "1",
|
||||||
|
"group": parts[4],
|
||||||
|
"id": parts[5],
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
def get_panes():
|
||||||
|
"""Get all tmux panes with full metadata."""
|
||||||
|
fmt = '#{session_name}|#{window_index}|#{pane_index}|#{pane_pid}|#{pane_title}|#{pane_width}x#{pane_height}|#{pane_active}|#{pane_current_command}|#{pane_start_command}|#{pane_tty}|#{pane_id}|#{window_name}|#{session_id}'
|
||||||
|
raw = run(f"tmux list-panes -a -F '{fmt}'")
|
||||||
|
panes = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split("|")
|
||||||
|
if len(parts) < 13:
|
||||||
|
continue
|
||||||
|
session, win, pane, pid, title, size, active, cmd, start_cmd, tty, pane_id, win_name, sess_id = parts[:13]
|
||||||
|
w, h = size.split("x") if "x" in size else ("0", "0")
|
||||||
|
panes.append({
|
||||||
|
"session": session,
|
||||||
|
"window_index": int(win),
|
||||||
|
"window_name": win_name,
|
||||||
|
"pane_index": int(pane),
|
||||||
|
"pane_id": pane_id,
|
||||||
|
"pid": int(pid) if pid.isdigit() else 0,
|
||||||
|
"title": title,
|
||||||
|
"width": int(w),
|
||||||
|
"height": int(h),
|
||||||
|
"active": active == "1",
|
||||||
|
"command": cmd,
|
||||||
|
"start_command": start_cmd,
|
||||||
|
"tty": tty,
|
||||||
|
"session_id": sess_id,
|
||||||
|
})
|
||||||
|
return panes
|
||||||
|
|
||||||
|
def extract_hermes_state(pane):
|
||||||
|
"""Try to extract hermes session info from a pane."""
|
||||||
|
info = {
|
||||||
|
"is_hermes": False,
|
||||||
|
"profile": None,
|
||||||
|
"model": None,
|
||||||
|
"provider": None,
|
||||||
|
"session_id": None,
|
||||||
|
"task": None,
|
||||||
|
}
|
||||||
|
title = pane.get("title", "")
|
||||||
|
cmd = pane.get("command", "")
|
||||||
|
start = pane.get("start_command", "")
|
||||||
|
|
||||||
|
# Detect hermes processes
|
||||||
|
is_hermes = any(k in (title + " " + cmd + " " + start).lower()
|
||||||
|
for k in ["hermes", "timmy", "mimo", "claude", "gpt"])
|
||||||
|
if not is_hermes and cmd not in ("python3", "python3.11", "bash", "zsh", "fish"):
|
||||||
|
return info
|
||||||
|
|
||||||
|
# Try reading pane content for model/provider clues
|
||||||
|
pane_content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -20 2>/dev/null")
|
||||||
|
|
||||||
|
# Extract model from pane content patterns
|
||||||
|
model_patterns = [
|
||||||
|
r"(?:mimo-v2-pro|claude-[\w.-]+|gpt-[\w.-]+|gemini-[\w.-]+|qwen[\w:.-]*)",
|
||||||
|
]
|
||||||
|
for pat in model_patterns:
|
||||||
|
m = re.search(pat, pane_content, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
info["model"] = m.group(0)
|
||||||
|
info["is_hermes"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Provider inference from model
|
||||||
|
model = (info["model"] or "").lower()
|
||||||
|
if "mimo" in model:
|
||||||
|
info["provider"] = "nous"
|
||||||
|
elif "claude" in model:
|
||||||
|
info["provider"] = "anthropic"
|
||||||
|
elif "gpt" in model:
|
||||||
|
info["provider"] = "openai"
|
||||||
|
elif "gemini" in model:
|
||||||
|
info["provider"] = "google"
|
||||||
|
elif "qwen" in model:
|
||||||
|
info["provider"] = "custom"
|
||||||
|
|
||||||
|
# Profile from session name
|
||||||
|
session = pane["session"].lower()
|
||||||
|
if "burn" in session:
|
||||||
|
info["profile"] = "burn"
|
||||||
|
elif session in ("dev", "0"):
|
||||||
|
info["profile"] = "default"
|
||||||
|
else:
|
||||||
|
info["profile"] = session
|
||||||
|
|
||||||
|
# Try to extract session ID (hermes uses UUIDs)
|
||||||
|
uuid_match = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', pane_content)
|
||||||
|
if uuid_match:
|
||||||
|
info["session_id"] = uuid_match[-1] # most recent
|
||||||
|
info["is_hermes"] = True
|
||||||
|
|
||||||
|
# Last prompt — grab the last user-like line
|
||||||
|
lines = pane_content.splitlines()
|
||||||
|
for line in reversed(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.startswith(("─", "│", "╭", "╰", "▸", "●", "○")) and len(stripped) > 10:
|
||||||
|
info["task"] = stripped[:200]
|
||||||
|
break
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def get_context_percent(pane):
|
||||||
|
"""Estimate context usage from pane content heuristics."""
|
||||||
|
content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -5 2>/dev/null")
|
||||||
|
# Look for context indicators like "ctx 45%" or "[░░░░░░░░░░]"
|
||||||
|
ctx_match = re.search(r'ctx\s*(\d+)%', content)
|
||||||
|
if ctx_match:
|
||||||
|
return int(ctx_match.group(1))
|
||||||
|
bar_match = re.search(r'\[(░+█*█*░*)\]', content)
|
||||||
|
if bar_match:
|
||||||
|
bar = bar_match.group(1)
|
||||||
|
filled = bar.count('█')
|
||||||
|
total = len(bar)
|
||||||
|
if total > 0:
|
||||||
|
return int((filled / total) * 100)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_manifest():
|
||||||
|
"""Build the full tmux state manifest."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
sessions = get_sessions()
|
||||||
|
panes = get_panes()
|
||||||
|
|
||||||
|
pane_manifests = []
|
||||||
|
for p in panes:
|
||||||
|
hermes = extract_hermes_state(p)
|
||||||
|
ctx = get_context_percent(p)
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"address": f"{p['session']}:{p['window_index']}.{p['pane_index']}",
|
||||||
|
"pane_id": p["pane_id"],
|
||||||
|
"pid": p["pid"],
|
||||||
|
"size": f"{p['width']}x{p['height']}",
|
||||||
|
"active": p["active"],
|
||||||
|
"command": p["command"],
|
||||||
|
"title": p["title"],
|
||||||
|
"profile": hermes["profile"],
|
||||||
|
"model": hermes["model"],
|
||||||
|
"provider": hermes["provider"],
|
||||||
|
"session_id": hermes["session_id"],
|
||||||
|
"task": hermes["task"],
|
||||||
|
"context_pct": ctx,
|
||||||
|
"is_hermes": hermes["is_hermes"],
|
||||||
|
}
|
||||||
|
pane_manifests.append(entry)
|
||||||
|
|
||||||
|
# Active pane summary
|
||||||
|
active_panes = [p for p in pane_manifests if p["active"]]
|
||||||
|
primary = active_panes[0] if active_panes else {}
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"timestamp_epoch": int(now.timestamp()),
|
||||||
|
"hostname": os.uname().nodename,
|
||||||
|
"sessions": sessions,
|
||||||
|
"panes": pane_manifests,
|
||||||
|
"summary": {
|
||||||
|
"total_sessions": len(sessions),
|
||||||
|
"total_panes": len(pane_manifests),
|
||||||
|
"hermes_panes": sum(1 for p in pane_manifests if p["is_hermes"]),
|
||||||
|
"active_pane": primary.get("address"),
|
||||||
|
"active_model": primary.get("model"),
|
||||||
|
"active_provider": primary.get("provider"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
manifest = build_manifest()
|
||||||
|
|
||||||
|
# Write manifest
|
||||||
|
with open(MANIFEST, "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
|
# Also write to ~/.hermes/tmux-state.json for compatibility
|
||||||
|
hermes_manifest = os.path.expanduser("~/.hermes/tmux-state.json")
|
||||||
|
os.makedirs(os.path.dirname(hermes_manifest), exist_ok=True)
|
||||||
|
with open(hermes_manifest, "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
|
print(f"[tmux-state] {manifest['summary']['total_panes']} panes, "
|
||||||
|
f"{manifest['summary']['hermes_panes']} hermes, "
|
||||||
|
f"active={manifest['summary']['active_pane']} "
|
||||||
|
f"@ {manifest['summary']['active_model']}")
|
||||||
|
print(f"[tmux-state] written to {MANIFEST}")
|
||||||
|
PYEOF
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updated_at": "2026-03-28T09:54:34.822062",
|
"updated_at": "2026-04-13T02:02:07.001824",
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"discord": [
|
"discord": [
|
||||||
{
|
{
|
||||||
@@ -27,11 +27,81 @@
|
|||||||
"name": "Timmy Time",
|
"name": "Timmy Time",
|
||||||
"type": "group",
|
"type": "group",
|
||||||
"thread_id": null
|
"thread_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:85",
|
||||||
|
"name": "Timmy Time / topic 85",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "85"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:111",
|
||||||
|
"name": "Timmy Time / topic 111",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:173",
|
||||||
|
"name": "Timmy Time / topic 173",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "173"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7635059073",
|
||||||
|
"name": "Trip T",
|
||||||
|
"type": "dm",
|
||||||
|
"thread_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:244",
|
||||||
|
"name": "Timmy Time / topic 244",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "244"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:972",
|
||||||
|
"name": "Timmy Time / topic 972",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "972"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:931",
|
||||||
|
"name": "Timmy Time / topic 931",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "931"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:957",
|
||||||
|
"name": "Timmy Time / topic 957",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "957"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:1297",
|
||||||
|
"name": "Timmy Time / topic 1297",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "1297"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "-1003664764329:1316",
|
||||||
|
"name": "Timmy Time / topic 1316",
|
||||||
|
"type": "group",
|
||||||
|
"thread_id": "1316"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"whatsapp": [],
|
"whatsapp": [],
|
||||||
|
"slack": [],
|
||||||
"signal": [],
|
"signal": [],
|
||||||
|
"mattermost": [],
|
||||||
|
"matrix": [],
|
||||||
|
"homeassistant": [],
|
||||||
"email": [],
|
"email": [],
|
||||||
"sms": []
|
"sms": [],
|
||||||
|
"dingtalk": [],
|
||||||
|
"feishu": [],
|
||||||
|
"wecom": [],
|
||||||
|
"wecom_callback": [],
|
||||||
|
"weixin": [],
|
||||||
|
"bluebubbles": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ Purpose:
|
|||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
Code Claw is a separate local runtime from Hermes/OpenClaw.
|
Code Claw is a separate local runtime from Hermes.
|
||||||
|
|
||||||
Current lane:
|
Current lane:
|
||||||
- runtime: local patched `~/code-claw`
|
- runtime: local patched `~/code-claw`
|
||||||
|
|||||||
218
config.yaml
218
config.yaml
@@ -1,31 +1,23 @@
|
|||||||
model:
|
model:
|
||||||
default: hermes4:14b
|
default: claude-opus-4-6
|
||||||
provider: custom
|
provider: anthropic
|
||||||
context_length: 65536
|
|
||||||
base_url: http://localhost:8081/v1
|
|
||||||
toolsets:
|
toolsets:
|
||||||
- all
|
- all
|
||||||
agent:
|
agent:
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
reasoning_effort: xhigh
|
reasoning_effort: medium
|
||||||
verbose: false
|
verbose: false
|
||||||
terminal:
|
terminal:
|
||||||
backend: local
|
backend: local
|
||||||
cwd: .
|
cwd: .
|
||||||
timeout: 180
|
timeout: 180
|
||||||
env_passthrough: []
|
|
||||||
docker_image: nikolaik/python-nodejs:python3.11-nodejs20
|
docker_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
docker_forward_env: []
|
docker_forward_env: []
|
||||||
singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20
|
singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
container_cpu: 1
|
container_cpu: 1
|
||||||
container_embeddings:
|
container_memory: 5120
|
||||||
provider: ollama
|
|
||||||
model: nomic-embed-text
|
|
||||||
base_url: http://localhost:11434/v1
|
|
||||||
|
|
||||||
memory: 5120
|
|
||||||
container_disk: 51200
|
container_disk: 51200
|
||||||
container_persistent: true
|
container_persistent: true
|
||||||
docker_volumes: []
|
docker_volumes: []
|
||||||
@@ -33,89 +25,74 @@ memory: 5120
|
|||||||
persistent_shell: true
|
persistent_shell: true
|
||||||
browser:
|
browser:
|
||||||
inactivity_timeout: 120
|
inactivity_timeout: 120
|
||||||
command_timeout: 30
|
|
||||||
record_sessions: false
|
record_sessions: false
|
||||||
checkpoints:
|
checkpoints:
|
||||||
enabled: true
|
enabled: false
|
||||||
max_snapshots: 50
|
max_snapshots: 50
|
||||||
compression:
|
compression:
|
||||||
enabled: true
|
enabled: true
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
target_ratio: 0.2
|
summary_model: qwen3:30b
|
||||||
protect_last_n: 20
|
summary_provider: custom
|
||||||
summary_model: ''
|
summary_base_url: http://localhost:11434/v1
|
||||||
summary_provider: ''
|
|
||||||
summary_base_url: ''
|
|
||||||
synthesis_model:
|
|
||||||
provider: custom
|
|
||||||
model: llama3:70b
|
|
||||||
base_url: http://localhost:8081/v1
|
|
||||||
|
|
||||||
smart_model_routing:
|
smart_model_routing:
|
||||||
enabled: true
|
enabled: false
|
||||||
max_simple_chars: 400
|
max_simple_chars: 160
|
||||||
max_simple_words: 75
|
max_simple_words: 28
|
||||||
cheap_model:
|
cheap_model: {}
|
||||||
provider: 'ollama'
|
|
||||||
model: 'gemma2:2b'
|
|
||||||
base_url: 'http://localhost:11434/v1'
|
|
||||||
api_key: ''
|
|
||||||
auxiliary:
|
auxiliary:
|
||||||
vision:
|
vision:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
timeout: 30
|
|
||||||
web_extract:
|
web_extract:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
compression:
|
compression:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
session_search:
|
session_search:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
skills_hub:
|
skills_hub:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
approval:
|
approval:
|
||||||
provider: auto
|
provider: auto
|
||||||
model: ''
|
model: ''
|
||||||
base_url: ''
|
base_url: ''
|
||||||
api_key: ''
|
api_key: ''
|
||||||
mcp:
|
mcp:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
flush_memories:
|
flush_memories:
|
||||||
provider: auto
|
provider: custom
|
||||||
model: ''
|
model: qwen3:30b
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: 'ollama'
|
||||||
display:
|
display:
|
||||||
compact: false
|
compact: false
|
||||||
personality: ''
|
personality: ''
|
||||||
resume_display: full
|
resume_display: full
|
||||||
busy_input_mode: interrupt
|
|
||||||
bell_on_complete: false
|
bell_on_complete: false
|
||||||
show_reasoning: false
|
show_reasoning: false
|
||||||
streaming: false
|
streaming: false
|
||||||
show_cost: false
|
show_cost: false
|
||||||
skin: timmy
|
skin: timmy
|
||||||
tool_progress_command: false
|
|
||||||
tool_progress: all
|
tool_progress: all
|
||||||
privacy:
|
privacy:
|
||||||
redact_pii: true
|
redact_pii: false
|
||||||
tts:
|
tts:
|
||||||
provider: edge
|
provider: edge
|
||||||
edge:
|
edge:
|
||||||
@@ -124,7 +101,7 @@ tts:
|
|||||||
voice_id: pNInz6obpgDQGcFmaJgB
|
voice_id: pNInz6obpgDQGcFmaJgB
|
||||||
model_id: eleven_multilingual_v2
|
model_id: eleven_multilingual_v2
|
||||||
openai:
|
openai:
|
||||||
model: '' # disabled — use edge TTS locally
|
model: gpt-4o-mini-tts
|
||||||
voice: alloy
|
voice: alloy
|
||||||
neutts:
|
neutts:
|
||||||
ref_audio: ''
|
ref_audio: ''
|
||||||
@@ -160,7 +137,6 @@ delegation:
|
|||||||
provider: ''
|
provider: ''
|
||||||
base_url: ''
|
base_url: ''
|
||||||
api_key: ''
|
api_key: ''
|
||||||
max_iterations: 50
|
|
||||||
prefill_messages_file: ''
|
prefill_messages_file: ''
|
||||||
honcho: {}
|
honcho: {}
|
||||||
timezone: ''
|
timezone: ''
|
||||||
@@ -174,16 +150,7 @@ approvals:
|
|||||||
command_allowlist: []
|
command_allowlist: []
|
||||||
quick_commands: {}
|
quick_commands: {}
|
||||||
personalities: {}
|
personalities: {}
|
||||||
mesh:
|
|
||||||
enabled: true
|
|
||||||
blackboard_provider: local
|
|
||||||
nostr_discovery: true
|
|
||||||
consensus_mode: competitive
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
sovereign_audit: true
|
|
||||||
no_phone_home: true
|
|
||||||
|
|
||||||
redact_secrets: true
|
redact_secrets: true
|
||||||
tirith_enabled: true
|
tirith_enabled: true
|
||||||
tirith_path: tirith
|
tirith_path: tirith
|
||||||
@@ -193,55 +160,66 @@ security:
|
|||||||
enabled: false
|
enabled: false
|
||||||
domains: []
|
domains: []
|
||||||
shared_files: []
|
shared_files: []
|
||||||
_config_version: 10
|
# Author whitelist for task router (Issue #132)
|
||||||
platforms:
|
# Only users in this list can submit tasks via Gitea issues
|
||||||
api_server:
|
# Empty list = deny all (secure by default)
|
||||||
enabled: true
|
# Set via env var TIMMY_AUTHOR_WHITELIST as comma-separated list
|
||||||
extra:
|
author_whitelist: []
|
||||||
host: 0.0.0.0
|
_config_version: 9
|
||||||
port: 8642
|
|
||||||
session_reset:
|
session_reset:
|
||||||
mode: none
|
mode: none
|
||||||
idle_minutes: 0
|
idle_minutes: 0
|
||||||
custom_providers:
|
custom_providers:
|
||||||
- name: Local llama.cpp
|
- name: Local Ollama
|
||||||
base_url: http://localhost:8081/v1
|
base_url: http://localhost:11434/v1
|
||||||
api_key: none
|
api_key: ollama
|
||||||
model: hermes4:14b
|
model: qwen3:30b
|
||||||
# ── Emergency cloud provider — not used by default or any cron job.
|
|
||||||
# Available for explicit override only: hermes --model gemini-2.5-pro
|
|
||||||
- name: Google Gemini (emergency only)
|
|
||||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
|
||||||
api_key_env: GEMINI_API_KEY
|
|
||||||
model: gemini-2.5-pro
|
|
||||||
system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 read\
|
system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 read\
|
||||||
\ it, live it.\nYou run locally on your owner's machine via llama.cpp. You never\
|
\ it, live it.\nYou run locally on your owner's machine via Ollama. You never phone\
|
||||||
\ phone home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
|
\ home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
|
||||||
When you don't know something, say so. Refusal over fabrication.\nSovereignty and\
|
Source distinction: Tag every factual claim inline. Default is [generated] — you\
|
||||||
\ service always.\n"
|
\ are pattern-matching from training data. Only use [retrieved] when you can name\
|
||||||
|
\ the specific tool call or document from THIS conversation that provided the fact.\
|
||||||
|
\ If no tool was called, every claim is [generated]. No exceptions.\n\
|
||||||
|
Refusal over fabrication: When you generate a specific claim — a date, a number,\
|
||||||
|
\ a price, a version, a URL, a current event — and you cannot name a source from\
|
||||||
|
\ this conversation, say 'I don't know' instead. Do not guess. Do not hedge with\
|
||||||
|
\ 'probably' or 'approximately' as a substitute for knowledge. If your only source\
|
||||||
|
\ is training data and the claim could be wrong or outdated, the honest answer is\
|
||||||
|
\ 'I don't know — I can look this up if you'd like.' Prefer a true 'I don't know'\
|
||||||
|
\ over a plausible fabrication.\nSovereignty and service always.\n"
|
||||||
skills:
|
skills:
|
||||||
creation_nudge_interval: 15
|
creation_nudge_interval: 15
|
||||||
DISCORD_HOME_CHANNEL: '1476292315814297772'
|
|
||||||
providers:
|
# ── Fallback Model ────────────────────────────────────────────────────
|
||||||
ollama:
|
# Automatic provider failover when primary is unavailable.
|
||||||
base_url: http://localhost:11434/v1
|
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||||||
model: hermes3:latest
|
# overload (529), service errors (503), or connection failures.
|
||||||
mcp_servers:
|
#
|
||||||
morrowind:
|
# Supported providers:
|
||||||
command: python3
|
# openrouter (OPENROUTER_API_KEY) — routes to any model
|
||||||
args:
|
# openai-codex (OAuth — hermes login) — OpenAI Codex
|
||||||
- /Users/apayne/.timmy/morrowind/mcp_server.py
|
# nous (OAuth — hermes login) — Nous Portal
|
||||||
env: {}
|
# zai (ZAI_API_KEY) — Z.AI / GLM
|
||||||
timeout: 30
|
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
|
||||||
crucible:
|
# minimax (MINIMAX_API_KEY) — MiniMax
|
||||||
command: /Users/apayne/.hermes/hermes-agent/venv/bin/python3
|
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||||||
args:
|
#
|
||||||
- /Users/apayne/.hermes/bin/crucible_mcp_server.py
|
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
|
||||||
env: {}
|
#
|
||||||
timeout: 120
|
# fallback_model:
|
||||||
connect_timeout: 60
|
# provider: openrouter
|
||||||
fallback_model:
|
# model: anthropic/claude-sonnet-4
|
||||||
provider: ollama
|
#
|
||||||
model: hermes3:latest
|
# ── Smart Model Routing ────────────────────────────────────────────────
|
||||||
base_url: http://localhost:11434/v1
|
# Optional cheap-vs-strong routing for simple turns.
|
||||||
api_key: ''
|
# Keeps the primary model for complex work, but can route short/simple
|
||||||
|
# messages to a cheaper model across providers.
|
||||||
|
#
|
||||||
|
# smart_model_routing:
|
||||||
|
# enabled: true
|
||||||
|
# max_simple_chars: 160
|
||||||
|
# max_simple_words: 28
|
||||||
|
# cheap_model:
|
||||||
|
# provider: openrouter
|
||||||
|
# model: google/gemini-2.5-flash
|
||||||
|
|||||||
212
cron/jobs-backup-2026-04-10.json
Normal file
212
cron/jobs-backup-2026-04-10.json
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"job_id": "9e0624269ba7",
|
||||||
|
"name": "Triage Heartbeat",
|
||||||
|
"schedule": "every 15m",
|
||||||
|
"state": "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "e29eda4a8548",
|
||||||
|
"name": "PR Review Sweep",
|
||||||
|
"schedule": "every 30m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "a77a87392582",
|
||||||
|
"name": "Health Monitor",
|
||||||
|
"schedule": "every 5m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "5e9d952871bc",
|
||||||
|
"name": "Agent Status Check",
|
||||||
|
"schedule": "every 10m",
|
||||||
|
"state": "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "36fb2f630a17",
|
||||||
|
"name": "Hermes Philosophy Loop",
|
||||||
|
"schedule": "every 1440m",
|
||||||
|
"state": "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "b40a96a2f48c",
|
||||||
|
"name": "wolf-eval-cycle",
|
||||||
|
"schedule": "every 240m",
|
||||||
|
"state": "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "4204e568b862",
|
||||||
|
"name": "Burn Mode \u2014 Timmy Orchestrator",
|
||||||
|
"schedule": "every 15m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "0944a976d034",
|
||||||
|
"name": "Burn Mode",
|
||||||
|
"schedule": "every 15m",
|
||||||
|
"state": "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "62016b960fa0",
|
||||||
|
"name": "velocity-engine",
|
||||||
|
"schedule": "every 30m",
|
||||||
|
"state": "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "e9d49eeff79c",
|
||||||
|
"name": "weekly-skill-extraction",
|
||||||
|
"schedule": "every 10080m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "75c74a5bb563",
|
||||||
|
"name": "tower-tick",
|
||||||
|
"schedule": "every 1m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "390a19054d4c",
|
||||||
|
"name": "Burn Deadman",
|
||||||
|
"schedule": "every 30m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "05e3c13498fa",
|
||||||
|
"name": "Morning Report \u2014 Burn Mode",
|
||||||
|
"schedule": "0 6 * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "64fe44b512b9",
|
||||||
|
"name": "evennia-morning-report",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "3896a7fd9747",
|
||||||
|
"name": "Gitea Priority Inbox",
|
||||||
|
"schedule": "every 3m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "f64c2709270a",
|
||||||
|
"name": "Config Drift Guard",
|
||||||
|
"schedule": "every 30m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "fc6a75b7102a",
|
||||||
|
"name": "Gitea Event Watcher",
|
||||||
|
"schedule": "every 2m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "12e59648fb06",
|
||||||
|
"name": "Burndown Night Watcher",
|
||||||
|
"schedule": "every 15m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "35d3ada9cf8f",
|
||||||
|
"name": "Mempalace Forge \u2014 Issue Analysis",
|
||||||
|
"schedule": "every 60m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "190b6fb8dc91",
|
||||||
|
"name": "Mempalace Watchtower \u2014 Fleet Health",
|
||||||
|
"schedule": "every 30m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "710ab589813c",
|
||||||
|
"name": "Ezra Health Monitor",
|
||||||
|
"schedule": "every 15m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "a0a9cce4575c",
|
||||||
|
"name": "daily-poka-yoke-ultraplan-awesometools",
|
||||||
|
"schedule": "every 1440m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "adc3a51457bd",
|
||||||
|
"name": "vps-agent-dispatch",
|
||||||
|
"schedule": "every 10m",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "afd2c4eac44d",
|
||||||
|
"name": "Project Mnemosyne Nightly Burn v2",
|
||||||
|
"schedule": "*/30 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "f3a3c2832af0",
|
||||||
|
"name": "gemma4-multimodal-worker",
|
||||||
|
"schedule": "once in 15m",
|
||||||
|
"state": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "c17a85c19838",
|
||||||
|
"name": "know-thy-father-analyzer",
|
||||||
|
"schedule": "0 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "2490fc01a14d",
|
||||||
|
"name": "Testament Burn - 10min work loop",
|
||||||
|
"schedule": "*/10 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "f5e858159d97",
|
||||||
|
"name": "Timmy Foundation Burn \u2014 15min PR loop",
|
||||||
|
"schedule": "*/15 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "5e262fb9bdce",
|
||||||
|
"name": "nightwatch-health-monitor",
|
||||||
|
"schedule": "*/15 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "f2b33a9dcf96",
|
||||||
|
"name": "nightwatch-mempalace-mine",
|
||||||
|
"schedule": "0 */2 * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "82cb9e76c54d",
|
||||||
|
"name": "nightwatch-backlog-burn",
|
||||||
|
"schedule": "0 */4 * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "d20e42a52863",
|
||||||
|
"name": "beacon-sprint",
|
||||||
|
"schedule": "*/15 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "579269489961",
|
||||||
|
"name": "testament-story",
|
||||||
|
"schedule": "*/15 * * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "2e5f9140d1ab",
|
||||||
|
"name": "nightwatch-research",
|
||||||
|
"schedule": "0 */2 * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "aeba92fd65e6",
|
||||||
|
"name": "timmy-dreams",
|
||||||
|
"schedule": "30 5 * * *",
|
||||||
|
"state": "scheduled"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -168,7 +168,65 @@
|
|||||||
"paused_reason": null,
|
"paused_reason": null,
|
||||||
"skills": [],
|
"skills": [],
|
||||||
"skill": null
|
"skill": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "overnight-rd-nightly",
|
||||||
|
"name": "Overnight R&D Loop",
|
||||||
|
"prompt": "Run the overnight R&D automation: Deep Dive paper synthesis, tightening loop for tool-use training data, DPO export sweep, morning briefing prep. All local inference via Ollama.",
|
||||||
|
"schedule": {
|
||||||
|
"kind": "cron",
|
||||||
|
"expr": "0 2 * * *",
|
||||||
|
"display": "0 2 * * * (10 PM EDT)"
|
||||||
|
},
|
||||||
|
"schedule_display": "Nightly at 10 PM EDT",
|
||||||
|
"repeat": {
|
||||||
|
"times": null,
|
||||||
|
"completed": 0
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"created_at": "2026-04-13T02:00:00+00:00",
|
||||||
|
"next_run_at": null,
|
||||||
|
"last_run_at": null,
|
||||||
|
"last_status": null,
|
||||||
|
"last_error": null,
|
||||||
|
"deliver": "local",
|
||||||
|
"origin": "perplexity/overnight-rd-automation",
|
||||||
|
"state": "scheduled",
|
||||||
|
"paused_at": null,
|
||||||
|
"paused_reason": null,
|
||||||
|
"skills": [],
|
||||||
|
"skill": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tmux-supervisor-513",
|
||||||
|
"name": "Autonomous Cron Supervisor",
|
||||||
|
"prompt": "Load the tmux-supervisor skill and execute the monitoring protocol.\n\nCheck both `dev` and `timmy` tmux sessions for idle panes. Only send Telegram notifications on actionable events (idle, overflow, failure). Be silent when all agents are working.\n\nSteps:\n1. List all tmux sessions (skip 'Alexander')\n2. For each session, list windows and panes\n3. Capture each pane and classify state (idle vs active)\n4. For idle panes: read context, craft context-aware prompt\n5. Send /queue prompts to idle panes\n6. Verify prompts landed\n7. Only notify via Telegram if:\n - A pane was prompted (idle detected)\n - A pane shows context overflow (>80%)\n - A pane is stuck or crashed\n8. If all panes are active: respond with [SILENT]",
|
||||||
|
"schedule": {
|
||||||
|
"kind": "interval",
|
||||||
|
"minutes": 7,
|
||||||
|
"display": "every 7m"
|
||||||
|
},
|
||||||
|
"schedule_display": "every 7m",
|
||||||
|
"repeat": {
|
||||||
|
"times": null,
|
||||||
|
"completed": 0
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"created_at": "2026-04-15T03:00:00.000000+00:00",
|
||||||
|
"next_run_at": null,
|
||||||
|
"last_run_at": null,
|
||||||
|
"last_status": null,
|
||||||
|
"last_error": null,
|
||||||
|
"deliver": "telegram",
|
||||||
|
"origin": null,
|
||||||
|
"state": "scheduled",
|
||||||
|
"paused_at": null,
|
||||||
|
"paused_reason": null,
|
||||||
|
"skills": [
|
||||||
|
"tmux-supervisor"
|
||||||
|
],
|
||||||
|
"skill": "tmux-supervisor"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated_at": "2026-04-07T15:00:00+00:00"
|
"updated_at": "2026-04-13T02:00:00+00:00"
|
||||||
}
|
}
|
||||||
9
cron/pipeline-scheduler.yml
Normal file
9
cron/pipeline-scheduler.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
- name: Nightly Pipeline Scheduler
|
||||||
|
schedule: '*/30 18-23,0-8 * * *' # Every 30 min, off-peak hours only
|
||||||
|
tasks:
|
||||||
|
- name: Check and start pipelines
|
||||||
|
shell: "bash scripts/nightly-pipeline-scheduler.sh"
|
||||||
|
env:
|
||||||
|
PIPELINE_TOKEN_LIMIT: "500000"
|
||||||
|
PIPELINE_PEAK_START: "9"
|
||||||
|
PIPELINE_PEAK_END: "18"
|
||||||
14
cron/vps/allegro-crontab-backup.txt
Normal file
14
cron/vps/allegro-crontab-backup.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
0 6 * * * /bin/bash /root/wizards/scripts/model_download_guard.sh >> /var/log/model_guard.log 2>&1
|
||||||
|
|
||||||
|
# Allegro Hybrid Heartbeat — quick wins every 15 min
|
||||||
|
*/15 * * * * /usr/bin/python3 /root/allegro/heartbeat_daemon.py >> /var/log/allegro_heartbeat.log 2>&1
|
||||||
|
|
||||||
|
# Allegro Burn Mode Cron Jobs - Deployed via issue #894
|
||||||
|
|
||||||
|
0 6 * * * cd /root/.hermes && python3 -c "import hermes_agent; from hermes_tools import terminal; output = terminal('echo \"Morning Report: $(date)\"'); print(output.get('output', ''))" >> /root/.hermes/logs/morning-report-$(date +\%Y\%m\%d).log 2>&1 # Allegro Morning Report at 0600
|
||||||
|
|
||||||
|
0,30 * * * * cd /root/.hermes && python3 /root/.hermes/retry_wrapper.py "python3 allegro/quick-lane-check.py" >> burn-logs/quick-lane-$(date +\%Y\%m\%d).log 2>&1 # Allegro Burn Loop #1 (with retry)
|
||||||
|
15,45 * * * * cd /root/.hermes && python3 /root/.hermes/retry_wrapper.py "python3 allegro/burn-mode-validator.py" >> burn-logs/validator-$(date +\%Y\%m\%d).log 2>&1 # Allegro Burn Loop #2 (with retry)
|
||||||
|
|
||||||
|
*/2 * * * * /root/wizards/bezalel/dead_man_monitor.sh
|
||||||
|
*/2 * * * * /root/wizards/allegro/bin/config-deadman.sh
|
||||||
10
cron/vps/bezalel-crontab-backup.txt
Normal file
10
cron/vps/bezalel-crontab-backup.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
0 2 * * * /root/wizards/bezalel/run_nightly_watch.sh
|
||||||
|
0 3 * * * /root/wizards/bezalel/mempalace_nightly.sh
|
||||||
|
*/10 * * * * pgrep -f "act_runner daemon" > /dev/null || (cd /opt/gitea-runner && nohup ./act_runner daemon > /var/log/gitea-runner.log 2>&1 &)
|
||||||
|
30 3 * * * /root/wizards/bezalel/backup_databases.sh
|
||||||
|
*/15 * * * * /root/wizards/bezalel/meta_heartbeat.sh
|
||||||
|
0 4 * * * /root/wizards/bezalel/secret_guard.sh
|
||||||
|
0 4 * * * /usr/bin/env bash /root/timmy-home/scripts/backup_pipeline.sh >> /var/log/timmy/backup_pipeline_cron.log 2>&1
|
||||||
|
0 6 * * * /usr/bin/python3 /root/wizards/bezalel/ultraplan.py >> /var/log/bezalel-ultraplan.log 2>&1
|
||||||
|
@reboot /root/wizards/bezalel/emacs-daemon-start.sh
|
||||||
|
@reboot /root/wizards/bezalel/ngircd-start.sh
|
||||||
13
cron/vps/ezra-crontab-backup.txt
Normal file
13
cron/vps/ezra-crontab-backup.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Burn Mode Cycles — 15 min autonomous loops
|
||||||
|
*/15 * * * * /root/wizards/ezra/bin/burn-mode.sh >> /root/wizards/ezra/reports/burn-cron.log 2>&1
|
||||||
|
|
||||||
|
# Household Snapshots — automated heartbeats and snapshots
|
||||||
|
# Ezra Self-Improvement Automation Suite
|
||||||
|
*/5 * * * * /usr/bin/python3 /root/wizards/ezra/tools/gitea_monitor.py >> /root/wizards/ezra/reports/gitea-monitor.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /root/wizards/ezra/tools/awareness_loop.py >> /root/wizards/ezra/reports/awareness-loop.log 2>&1
|
||||||
|
*/10 * * * * /usr/bin/python3 /root/wizards/ezra/tools/cron_health_monitor.py >> /root/wizards/ezra/reports/cron-health.log 2>&1
|
||||||
|
0 6 * * * /usr/bin/python3 /root/wizards/ezra/tools/morning_kt_compiler.py >> /root/wizards/ezra/reports/morning-kt.log 2>&1
|
||||||
|
5 6 * * * /usr/bin/python3 /root/wizards/ezra/tools/burndown_generator.py >> /root/wizards/ezra/reports/burndown.log 2>&1
|
||||||
|
0 3 * * * /root/wizards/ezra/mempalace_nightly.sh >> /var/log/ezra_mempalace_cron.log 2>&1
|
||||||
|
*/15 * * * * GITEA_TOKEN=6de6aa...1117 /root/wizards/ezra/dispatch-direct.sh >> /root/wizards/ezra/dispatch-cron.log 2>&1
|
||||||
|
|
||||||
24
deploy/auto-commit-guard.plist
Normal file
24
deploy/auto-commit-guard.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>ai.timmy.auto-commit-guard</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>/Users/apayne/.hermes/bin/auto-commit-guard.sh</string>
|
||||||
|
<string>120</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/apayne/.hermes/logs/auto-commit-guard.stdout.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/apayne/.hermes/logs/auto-commit-guard.stderr.log</string>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/Users/apayne</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
21
deploy/gitea-a11y/README.md
Normal file
21
deploy/gitea-a11y/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Gitea Accessibility Fix - R4: Time Elements
|
||||||
|
|
||||||
|
WCAG 1.3.1: Relative timestamps lack machine-readable fallbacks.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Wrap relative timestamps in `<time datetime="...">` elements.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `custom/templates/custom/time_relative.tmpl` - Reusable `<time>` helper
|
||||||
|
- `custom/templates/repo/list_a11y.tmpl` - Explore/Repos list override
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r custom/templates/* /path/to/gitea/custom/templates/
|
||||||
|
systemctl restart gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Closes #554
|
||||||
27
deploy/gitea-a11y/custom/templates/custom/time_relative.tmpl
Normal file
27
deploy/gitea-a11y/custom/templates/custom/time_relative.tmpl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
Gitea a11y fix: R4 <time> elements for relative timestamps
|
||||||
|
Deploy to: custom/templates/custom/time_relative.tmpl
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{define "custom/time_relative"}}
|
||||||
|
{{if and .Time .Relative}}
|
||||||
|
<time datetime="{{.Time.Format "2006-01-02T15:04:05Z07:00"}}" title="{{.Time.Format "Jan 02, 2006 15:04"}}">
|
||||||
|
{{.Relative}}
|
||||||
|
</time>
|
||||||
|
{{else if .Relative}}
|
||||||
|
<span>{{.Relative}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "custom/time_from_unix"}}
|
||||||
|
{{if .Relative}}
|
||||||
|
<time datetime="" data-unix="{{.Unix}}" title="">{{.Relative}}</time>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var el = document.currentScript.previousElementSibling;
|
||||||
|
var unix = parseInt(el.getAttribute('data-unix'));
|
||||||
|
if (unix) { el.setAttribute('datetime', new Date(unix * 1000).toISOString()); el.setAttribute('title', new Date(unix * 1000).toLocaleString()); }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
27
deploy/gitea-a11y/custom/templates/repo/list_a11y.tmpl
Normal file
27
deploy/gitea-a11y/custom/templates/repo/list_a11y.tmpl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
Gitea a11y fix: R4 <time> elements for relative timestamps on repo list
|
||||||
|
Deploy to: custom/templates/repo/list_a11y.tmpl
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{/* Star count link with aria-label */}}
|
||||||
|
<a class="repo-card-star" href="{{.RepoLink}}/stars" aria-label="{{.NumStars}} stars" title="{{.NumStars}} stars">
|
||||||
|
<svg class="octicon octicon-star" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{.NumStars}}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{/* Fork count link with aria-label */}}
|
||||||
|
<a class="repo-card-fork" href="{{.RepoLink}}/forks" aria-label="{{.NumForks}} forks" title="{{.NumForks}} forks">
|
||||||
|
<svg class="octicon octicon-repo-forked" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-.878a2.25 2.25 0 111.5 0v.878a2.25 2.25 0 01-2.25 2.25h-1.5v2.128a2.251 2.251 0 11-1.5 0V8.5h-1.5A2.25 2.25 0 013.5 6.25v-.878a2.25 2.25 0 111.5 0zM5 3.25a.75.75 0 10-1.5 0 .75.75 0 001.5 0zm6.75.75a.75.75 0 100-1.5.75.75 0 000 1.5zm-3 8.75a.75.75 0 10-1.5 0 .75.75 0 001.5 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{.NumForks}}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{/* Relative timestamp with <time> element for a11y */}}
|
||||||
|
{{if .UpdatedUnix}}
|
||||||
|
<time datetime="{{.UpdatedUnix | TimeSinceISO}}" title="{{.UpdatedUnix | DateFmtLong}}" class="text-light">
|
||||||
|
{{.UpdatedUnix | TimeSince}}
|
||||||
|
</time>
|
||||||
|
{{end}}
|
||||||
110
docs/FLEET_BEHAVIOUR_HARDENING.md
Normal file
110
docs/FLEET_BEHAVIOUR_HARDENING.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Fleet Behaviour Hardening — Review & Action Plan
|
||||||
|
|
||||||
|
**Author:** @perplexity
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
**Context:** Alexander asked: "Is it the memory system or the behaviour guardrails?"
|
||||||
|
**Answer:** It's the guardrails. The memory system is adequate. The enforcement machinery is aspirational.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagnosis: Why the Fleet Isn't Smart Enough
|
||||||
|
|
||||||
|
After auditing SOUL.md, config.yaml, all 8 playbooks, the orchestrator, the guard scripts, and the v7.0.0 checkin, the pattern is clear:
|
||||||
|
|
||||||
|
**The fleet has excellent design documents and broken enforcement.**
|
||||||
|
|
||||||
|
| Layer | Design Quality | Enforcement Quality | Gap |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SOUL.md | Excellent | None — no code reads it at runtime | Philosophy without machinery |
|
||||||
|
| Playbooks (7 yaml) | Good lane map | Not invoked by orchestrator | Playbooks exist but nobody calls them |
|
||||||
|
| Guard scripts (9) | Solid code | 1 of 9 wired (#395 audit) | 89% of guards are dead code |
|
||||||
|
| Orchestrator | Sound design | Gateway dispatch is a no-op (#391) | Assigns issues but doesn't trigger work |
|
||||||
|
| Cycle Guard | Good 10-min rule | No cron/loop calls it | Discipline without enforcement |
|
||||||
|
| PR Reviewer | Clear rules | Runs every 30m (if scheduled) | Only guard that might actually fire |
|
||||||
|
| Memory (MemPalace) | Working code | Retrieval enforcer wired | Actually operational |
|
||||||
|
|
||||||
|
### The Core Problem
|
||||||
|
|
||||||
|
Agents pick up issues and produce output, but there is **no pre-task checklist** and **no post-task quality gate**. An agent can:
|
||||||
|
|
||||||
|
1. Start work without checking if someone else already did it
|
||||||
|
2. Produce output without running tests
|
||||||
|
3. Submit a PR without verifying it addresses the issue
|
||||||
|
4. Work for hours on something out of scope
|
||||||
|
5. Create duplicate branches/PRs without detection
|
||||||
|
|
||||||
|
The SOUL.md says "grounding before generation" but no code enforces it.
|
||||||
|
The playbooks define lanes but the orchestrator doesn't load them.
|
||||||
|
The guards exist but nothing calls them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Fleet Needs (Priority Order)
|
||||||
|
|
||||||
|
### 1. Pre-Task Gate (MISSING — this PR adds it)
|
||||||
|
|
||||||
|
Before an agent starts any issue:
|
||||||
|
- [ ] Check if issue is already assigned to another agent
|
||||||
|
- [ ] Check if a branch already exists for this issue
|
||||||
|
- [ ] Check if a PR already exists for this issue
|
||||||
|
- [ ] Load relevant MemPalace context (retrieval enforcer)
|
||||||
|
- [ ] Verify the agent has the right lane for this work (playbook check)
|
||||||
|
|
||||||
|
### 2. Post-Task Gate (MISSING — this PR adds it)
|
||||||
|
|
||||||
|
Before an agent submits a PR:
|
||||||
|
- [ ] Verify the diff addresses the issue title/body
|
||||||
|
- [ ] Run syntax_guard.py on changed files
|
||||||
|
- [ ] Check for duplicate PRs targeting the same issue
|
||||||
|
- [ ] Verify branch name follows convention
|
||||||
|
- [ ] Run tests if they exist for changed files
|
||||||
|
|
||||||
|
### 3. Wire the Existing Guards (8 of 9 are dead code)
|
||||||
|
|
||||||
|
Per #395 audit:
|
||||||
|
- Pre-commit hooks: need symlink on every machine
|
||||||
|
- Cycle guard: need cron/loop integration
|
||||||
|
- Forge health check: need cron entry
|
||||||
|
- Smoke test + deploy validate: need deploy script integration
|
||||||
|
|
||||||
|
### 4. Orchestrator Dispatch Actually Works
|
||||||
|
|
||||||
|
Per #391 audit: the orchestrator scores and assigns but the gateway dispatch just writes to `/tmp/hermes-dispatch.log`. Nobody reads that file. The dispatch needs to either:
|
||||||
|
- Trigger `hermes` CLI on the target machine, or
|
||||||
|
- Post a webhook that the agent loop picks up
|
||||||
|
|
||||||
|
### 5. Agent Self-Assessment Loop
|
||||||
|
|
||||||
|
After completing work, agents should answer:
|
||||||
|
- Did I address the issue as stated?
|
||||||
|
- Did I stay in scope?
|
||||||
|
- Did I check the palace for prior work?
|
||||||
|
- Did I run verification?
|
||||||
|
|
||||||
|
This is what SOUL.md calls "the apparatus that gives these words teeth."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Working (Don't Touch)
|
||||||
|
|
||||||
|
- **MemPalace sovereign_store.py** — SQLite + FTS5 + HRR, operational
|
||||||
|
- **Retrieval enforcer** — wired to SovereignStore as of 14 hours ago
|
||||||
|
- **Wake-up protocol** — palace-first boot sequence
|
||||||
|
- **PR reviewer playbook** — clear rules, well-scoped
|
||||||
|
- **Issue triager playbook** — comprehensive lane map with 11 agents
|
||||||
|
- **Cycle guard code** — solid 10-min slice discipline (just needs wiring)
|
||||||
|
- **Config drift guard** — active cron, working
|
||||||
|
- **Dead man switch** — active, working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
The memory system is not the bottleneck. The behaviour guardrails are. Specifically:
|
||||||
|
|
||||||
|
1. **Add `task_gate.py`** — pre-task and post-task quality gates that every agent loop calls
|
||||||
|
2. **Wire cycle_guard.py** — add start/complete calls to agent loop
|
||||||
|
3. **Wire pre-commit hooks** — deploy script should symlink on provision
|
||||||
|
4. **Fix orchestrator dispatch** — make it actually trigger work, not just log
|
||||||
|
|
||||||
|
This PR adds item 1. Items 2-4 need SSH access and are flagged for Timmy/Allegro.
|
||||||
150
docs/a11y-audit-2026-04-13.md
Normal file
150
docs/a11y-audit-2026-04-13.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Visual Accessibility Audit — Foundation Web Properties
|
||||||
|
|
||||||
|
**Issue:** timmy-config #492
|
||||||
|
**Date:** 2026-04-13
|
||||||
|
**Label:** gemma-4-multimodal
|
||||||
|
**Scope:** forge.alexanderwhitestone.com (Gitea 1.25.4)
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The Foundation's primary accessible web property is the Gitea forge. The Matrix homeserver (matrix.timmy.foundation) is currently unreachable (DNS/SSL issues). This audit covers the forge across three page types: Homepage, Login, and Explore/Repositories.
|
||||||
|
|
||||||
|
**Overall: 6 WCAG 2.1 AA violations found, 4 best-practice recommendations.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages Audited
|
||||||
|
|
||||||
|
| Page | URL | Status |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Homepage | forge.alexanderwhitestone.com | Live |
|
||||||
|
| Sign In | forge.alexanderwhitestone.com/user/login | Live |
|
||||||
|
| Explore Repos | forge.alexanderwhitestone.com/explore/repos | Live |
|
||||||
|
| Matrix/Element | matrix.timmy.foundation | DOWN (DNS/SSL) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P1 — Violations (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
#### V1: No Skip Navigation Link (2.4.1)
|
||||||
|
- **Pages:** All
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Description:** No "Skip to content" link exists. Keyboard users must tab through the full navigation on every page load.
|
||||||
|
- **Evidence:** Programmatic check returned `skipNav: false`
|
||||||
|
- **Fix:** Add `<a href="#main" class="skip-link">Skip to content</a>` visually hidden until focused.
|
||||||
|
|
||||||
|
#### V2: 25 Form Inputs Without Labels (1.3.1, 3.3.2)
|
||||||
|
- **Pages:** Explore/Repositories (filter dropdowns)
|
||||||
|
- **Severity:** High
|
||||||
|
- **Description:** The search input and all radio buttons in the Filter/Sort dropdowns lack programmatic label associations.
|
||||||
|
- **Evidence:** Programmatic check found 25 inputs without `label[for=]`, `aria-label`, or `aria-labelledby`
|
||||||
|
- **Affected inputs:** `q` (search), `archived` (x2), `fork` (x2), `mirror` (x2), `template` (x2), `private` (x2), `sort` (x12), `clear-filter` (x1)
|
||||||
|
- **Fix:** Add `aria-label="Search repositories"` to search input. Add `aria-label` to each radio button group and individual options.
|
||||||
|
|
||||||
|
#### V3: Low-Contrast Footer Text (1.4.3)
|
||||||
|
- **Pages:** All
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Description:** Footer text (version, page render time) appears light gray on white, likely failing the 4.5:1 contrast ratio.
|
||||||
|
- **Evidence:** 30 elements flagged as potential low-contrast suspects.
|
||||||
|
- **Fix:** Darken footer text to at least `#767676` on white (4.54:1 ratio).
|
||||||
|
|
||||||
|
#### V4: Green Link Color Fails Contrast (1.4.3)
|
||||||
|
- **Pages:** Homepage
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Description:** Inline links use medium-green (~#609926) on white. This shade typically fails 4.5:1 for normal body text.
|
||||||
|
- **Evidence:** Visual analysis identified green links ("run the binary", "Docker", "contributing") as potentially failing.
|
||||||
|
- **Fix:** Darken link color to at least `#507020` or add an underline for non-color differentiation (SC 1.4.1).
|
||||||
|
|
||||||
|
#### V5: Missing Header/Banner Landmark (1.3.1)
|
||||||
|
- **Pages:** All
|
||||||
|
- **Severity:** Low
|
||||||
|
- **Description:** No `<header>` or `role="banner"` element found. The navigation bar is a `<nav>` but not wrapped in a banner landmark.
|
||||||
|
- **Evidence:** `landmarks.banner: 0`
|
||||||
|
- **Fix:** Wrap the top navigation in `<header>` or add `role="banner"`.
|
||||||
|
|
||||||
|
#### V6: Heading Hierarchy Issue (1.3.1)
|
||||||
|
- **Pages:** Login
|
||||||
|
- **Severity:** Low
|
||||||
|
- **Description:** The Sign In heading is `<h4>` rather than `<h1>`, breaking the heading hierarchy. The page has no `<h1>`.
|
||||||
|
- **Evidence:** Accessibility tree shows `heading "Sign In" [level=4]`
|
||||||
|
- **Fix:** Use `<h1>` for "Sign In" on the login page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2 — Best Practice Recommendations
|
||||||
|
|
||||||
|
#### R1: Add Password Visibility Toggle
|
||||||
|
- **Page:** Login
|
||||||
|
- **Description:** No show/hide toggle on the password field. This helps users with cognitive or motor impairments verify input.
|
||||||
|
|
||||||
|
#### R2: Add `aria-required` to Required Fields
|
||||||
|
- **Page:** Login
|
||||||
|
- **Evidence:** `inputsWithAriaRequired: 0` (no inputs marked as required)
|
||||||
|
- **Description:** The username field shows a red asterisk but has no `required` or `aria-required="true"` attribute.
|
||||||
|
|
||||||
|
#### R3: Improve Star/Fork Link Labels
|
||||||
|
- **Page:** Explore Repos
|
||||||
|
- **Description:** Star and fork counts are bare numbers (e.g., "0", "2"). Screen readers announce these without context.
|
||||||
|
- **Fix:** Add `aria-label="2 stars"` / `aria-label="0 forks"` to count links.
|
||||||
|
|
||||||
|
#### R4: Use `<time>` Elements for Timestamps
|
||||||
|
- **Page:** Explore Repos
|
||||||
|
- **Description:** Relative timestamps ("2 minutes ago") are human-readable but lack machine-readable fallbacks.
|
||||||
|
- **Fix:** Wrap in `<time datetime="2026-04-13T17:00:00Z">2 minutes ago</time>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Working Well
|
||||||
|
|
||||||
|
- **Color contrast (primary):** Black text on white backgrounds — excellent 21:1 ratio.
|
||||||
|
- **Heading structure (homepage):** Clean h1 > h2 > h3 hierarchy.
|
||||||
|
- **Landmark regions:** `<main>` and `<nav>` landmarks present.
|
||||||
|
- **Language attribute:** `lang="en-US"` set on `<html>`.
|
||||||
|
- **Link text:** Descriptive — no "click here" or "read more" patterns found.
|
||||||
|
- **Form layout:** Login form uses clean single-column with good spacing.
|
||||||
|
- **Submit button:** Full-width, good contrast, large touch target.
|
||||||
|
- **Navigation:** Simple, consistent across pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- **matrix.timmy.foundation:** Unreachable (DNS resolution failure / SSL cert mismatch). Should be re-audited when operational.
|
||||||
|
- **Evennia web client (localhost:4001):** Local-only, not publicly accessible.
|
||||||
|
- **WCAG AAA criteria:** This audit covers AA only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Priority
|
||||||
|
|
||||||
|
| Priority | Issue | Effort |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| P1 | V2: 25 unlabeled inputs | Medium |
|
||||||
|
| P1 | V1: Skip nav link | Small |
|
||||||
|
| P1 | V4: Green link contrast | Small |
|
||||||
|
| P1 | V3: Footer text contrast | Small |
|
||||||
|
| P2 | V6: Heading hierarchy | Small |
|
||||||
|
| P2 | V5: Banner landmark | Small |
|
||||||
|
| P2 | R1-R4: Best practices | Small |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automated Check Results
|
||||||
|
|
||||||
|
```
|
||||||
|
skipNav: false
|
||||||
|
headings: h1(3), h4(1)
|
||||||
|
imgsNoAlt: 0 / 1
|
||||||
|
inputsNoLabel: 25
|
||||||
|
genericLinks: 0
|
||||||
|
lowContrastSuspects: 30
|
||||||
|
inputsWithAriaRequired: 0
|
||||||
|
landmarks: main=1, nav=2, banner=0, contentinfo=2
|
||||||
|
hasLang: true (en-US)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated via visual + programmatic analysis of forge.alexanderwhitestone.com*
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Purpose:
|
Purpose:
|
||||||
- stand up the third wizard house as a Kimi-backed coding worker
|
- stand up the third wizard house as a Kimi-backed coding worker
|
||||||
- keep Hermes as the durable harness
|
- 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:
|
Local proof already achieved:
|
||||||
|
|
||||||
@@ -40,5 +40,5 @@ bin/deploy-allegro-house.sh root@167.99.126.228
|
|||||||
|
|
||||||
Important nuance:
|
Important nuance:
|
||||||
- the Hermes/Kimi lane is the proven path
|
- 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
|
- 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:
|
- Old-state risk:
|
||||||
- same class as main gateway, but isolated to fenrir profile state
|
- 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
|
#### 4. ai.timmy.kimi-heartbeat
|
||||||
- Plist: ~/Library/LaunchAgents/ai.timmy.kimi-heartbeat.plist
|
- Plist: ~/Library/LaunchAgents/ai.timmy.kimi-heartbeat.plist
|
||||||
- Command: `/bin/bash ~/.timmy/uniwizard/kimi-heartbeat.sh`
|
- 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:
|
List Timmy/Hermes launch agent files:
|
||||||
```bash
|
```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:
|
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.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.plist || true
|
||||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.hermes.gateway-fenrir.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
|
2. Kill manual loops
|
||||||
|
|||||||
179
docs/glitch-detection.md
Normal file
179
docs/glitch-detection.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 3D World Glitch Detection — Matrix Scanner
|
||||||
|
|
||||||
|
**Reference:** timmy-config#491
|
||||||
|
**Label:** gemma-4-multimodal
|
||||||
|
**Version:** 0.1.0
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Matrix Glitch Detector scans 3D web worlds for visual artifacts and
|
||||||
|
rendering anomalies. It uses browser automation to capture screenshots from
|
||||||
|
multiple camera angles, then sends them to a vision AI model for analysis
|
||||||
|
against a library of known glitch patterns.
|
||||||
|
|
||||||
|
## Detected Glitch Categories
|
||||||
|
|
||||||
|
| Category | Severity | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Floating Assets | HIGH | Objects not grounded — hovering above surfaces |
|
||||||
|
| Z-Fighting | MEDIUM | Coplanar surfaces flickering/competing for depth |
|
||||||
|
| Missing Textures | CRITICAL | Placeholder colors (magenta, checkerboard) |
|
||||||
|
| Clipping | HIGH | Geometry passing through other objects |
|
||||||
|
| Broken Normals | MEDIUM | Inside-out or incorrectly lit surfaces |
|
||||||
|
| Shadow Artifacts | LOW | Detached, mismatched, or acne shadows |
|
||||||
|
| LOD Popping | LOW | Abrupt level-of-detail transitions |
|
||||||
|
| Lightmap Errors | MEDIUM | Dark splotches, light leaks, baking failures |
|
||||||
|
| Water/Reflection | MEDIUM | Incorrect environment reflections |
|
||||||
|
| Skybox Seam | LOW | Visible seams at cubemap face edges |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
No external dependencies required — pure Python 3.10+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git
|
||||||
|
cd timmy-config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bin/matrix_glitch_detector.py https://matrix.example.com/world/alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Angle Scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bin/matrix_glitch_detector.py https://matrix.example.com/world/alpha \
|
||||||
|
--angles 8 \
|
||||||
|
--output glitch_report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bin/matrix_glitch_detector.py --demo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `url` | (required) | URL of the 3D world to scan |
|
||||||
|
| `--angles N` | 4 | Number of camera angles to capture |
|
||||||
|
| `--output PATH` | stdout | Output file for JSON report |
|
||||||
|
| `--min-severity` | info | Minimum severity: info/low/medium/high/critical |
|
||||||
|
| `--demo` | off | Run with simulated detections |
|
||||||
|
| `--verbose` | off | Enable verbose output |
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
The JSON report includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scan_id": "uuid",
|
||||||
|
"url": "https://...",
|
||||||
|
"timestamp": "ISO-8601",
|
||||||
|
"total_screenshots": 4,
|
||||||
|
"angles_captured": ["front", "right", "back", "left"],
|
||||||
|
"glitches": [
|
||||||
|
{
|
||||||
|
"id": "short-uuid",
|
||||||
|
"category": "floating_assets",
|
||||||
|
"name": "Floating Chair",
|
||||||
|
"description": "Office chair floating 0.3m above floor",
|
||||||
|
"severity": "high",
|
||||||
|
"confidence": 0.87,
|
||||||
|
"location_x": 35.2,
|
||||||
|
"location_y": 62.1,
|
||||||
|
"screenshot_index": 0,
|
||||||
|
"screenshot_angle": "front",
|
||||||
|
"timestamp": "ISO-8601"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_glitches": 4,
|
||||||
|
"by_severity": {"critical": 1, "high": 2, "medium": 1},
|
||||||
|
"by_category": {"floating_assets": 1, "missing_textures": 1, ...},
|
||||||
|
"highest_severity": "critical",
|
||||||
|
"clean_screenshots": 0
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"detector_version": "0.1.0",
|
||||||
|
"pattern_count": 10,
|
||||||
|
"reference": "timmy-config#491"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vision AI Integration
|
||||||
|
|
||||||
|
The detector supports any OpenAI-compatible vision API. Set these
|
||||||
|
environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VISION_API_KEY="your-api-key"
|
||||||
|
export VISION_API_BASE="https://api.openai.com/v1" # optional
|
||||||
|
export VISION_MODEL="gpt-4o" # optional, default: gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
For browser-based capture with `browser_vision`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BROWSER_VISION_SCRIPT="/path/to/browser_vision.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glitch Patterns
|
||||||
|
|
||||||
|
Pattern definitions live in `bin/glitch_patterns.py`. Each pattern includes:
|
||||||
|
|
||||||
|
- **category** — Enum matching the glitch type
|
||||||
|
- **detection_prompts** — Instructions for the vision model
|
||||||
|
- **visual_indicators** — What to look for in screenshots
|
||||||
|
- **confidence_threshold** — Minimum confidence to report
|
||||||
|
|
||||||
|
### Adding Custom Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
from glitch_patterns import GlitchPattern, GlitchCategory, GlitchSeverity
|
||||||
|
|
||||||
|
custom = GlitchPattern(
|
||||||
|
category=GlitchCategory.FLOATING_ASSETS,
|
||||||
|
name="Custom Glitch",
|
||||||
|
description="Your description",
|
||||||
|
severity=GlitchSeverity.MEDIUM,
|
||||||
|
detection_prompts=["Look for..."],
|
||||||
|
visual_indicators=["indicator 1", "indicator 2"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_glitch_detector.py -v
|
||||||
|
# or
|
||||||
|
python tests/test_glitch_detector.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/
|
||||||
|
matrix_glitch_detector.py — Main CLI entry point
|
||||||
|
glitch_patterns.py — Pattern definitions and prompt builder
|
||||||
|
tests/
|
||||||
|
test_glitch_detector.py — Unit and integration tests
|
||||||
|
docs/
|
||||||
|
glitch-detection.md — This documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Browser automation requires a headless browser environment
|
||||||
|
- Vision AI analysis depends on model availability and API limits
|
||||||
|
- Placeholder screenshots are generated when browser capture is unavailable
|
||||||
|
- Detection accuracy varies by scene complexity and lighting conditions
|
||||||
68
docs/overnight-rd.md
Normal file
68
docs/overnight-rd.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Overnight R&D Automation
|
||||||
|
|
||||||
|
**Schedule**: Nightly at 10 PM EDT (02:00 UTC)
|
||||||
|
**Duration**: ~2-4 hours (self-limiting, finishes before 6 AM morning report)
|
||||||
|
**Cost**: $0 — all local Ollama inference
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 1: Deep Dive Intelligence
|
||||||
|
Runs the `intelligence/deepdive/pipeline.py` from the-nexus:
|
||||||
|
- Aggregates arXiv CS.AI, CS.CL, CS.LG RSS feeds (last 24h)
|
||||||
|
- Fetches OpenAI, Anthropic, DeepMind blog updates
|
||||||
|
- Filters for relevance using sentence-transformers embeddings
|
||||||
|
- Synthesizes a briefing using local Gemma 4 12B
|
||||||
|
- Saves briefing to `~/briefings/`
|
||||||
|
|
||||||
|
### Phase 2: Tightening Loop
|
||||||
|
Exercises Timmy's local tool-use capability:
|
||||||
|
- 10 tasks × 3 cycles = 30 task attempts per night
|
||||||
|
- File reading, writing, searching against real workspace files
|
||||||
|
- Each result logged as JSONL for training data analysis
|
||||||
|
- Tests sovereignty compliance (SOUL.md alignment, banned provider detection)
|
||||||
|
|
||||||
|
### Phase 3: DPO Export
|
||||||
|
Sweeps overnight Hermes sessions for training pair extraction:
|
||||||
|
- Converts good conversation pairs into DPO training format
|
||||||
|
- Saves to `~/.timmy/training-data/dpo-pairs/`
|
||||||
|
|
||||||
|
### Phase 4: Morning Prep
|
||||||
|
Compiles overnight findings into `~/.timmy/overnight-rd/latest_summary.md`
|
||||||
|
for consumption by the 6 AM `good_morning_report` task.
|
||||||
|
|
||||||
|
## Approved Providers
|
||||||
|
|
||||||
|
| Slot | Provider | Model |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Synthesis | Ollama | gemma4:12b |
|
||||||
|
| Tool tasks | Ollama | hermes4:14b |
|
||||||
|
| Fallback | Ollama | gemma4:12b |
|
||||||
|
|
||||||
|
Anthropic is permanently banned (BANNED_PROVIDERS.yml, 2026-04-09).
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Path | Content |
|
||||||
|
|------|---------|
|
||||||
|
| `~/.timmy/overnight-rd/{run_id}/rd_log.jsonl` | Full task log |
|
||||||
|
| `~/.timmy/overnight-rd/{run_id}/rd_summary.md` | Run summary |
|
||||||
|
| `~/.timmy/overnight-rd/latest_summary.md` | Latest summary (for morning report) |
|
||||||
|
| `~/briefings/briefing_*.json` | Deep Dive briefings |
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Check the Huey consumer log:
|
||||||
|
```bash
|
||||||
|
tail -f ~/.timmy/timmy-config/logs/huey.log | grep overnight
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the latest run summary:
|
||||||
|
```bash
|
||||||
|
cat ~/.timmy/overnight-rd/latest_summary.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Deep Dive pipeline installed: `cd the-nexus/intelligence/deepdive && make install`
|
||||||
|
- Ollama running with gemma4:12b and hermes4:14b models
|
||||||
|
- Huey consumer running: `huey_consumer.py tasks.huey -w 2 -k thread`
|
||||||
@@ -14,7 +14,7 @@ from crewai.tools import BaseTool
|
|||||||
|
|
||||||
OPENROUTER_API_KEY = os.getenv(
|
OPENROUTER_API_KEY = os.getenv(
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"dsk-or-v1-f60c89db12040267458165cf192e815e339eb70548e4a0a461f5f0f69e6ef8b0",
|
os.environ.get("OPENROUTER_API_KEY", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
llm = LLM(
|
llm = LLM(
|
||||||
|
|||||||
@@ -2,135 +2,128 @@ schema_version: 1
|
|||||||
status: proposed
|
status: proposed
|
||||||
runtime_wiring: false
|
runtime_wiring: false
|
||||||
owner: timmy-config
|
owner: timmy-config
|
||||||
|
|
||||||
ownership:
|
ownership:
|
||||||
owns:
|
owns:
|
||||||
- routing doctrine for task classes
|
- routing doctrine for task classes
|
||||||
- sidecar-readable per-agent fallback portfolios
|
- sidecar-readable per-agent fallback portfolios
|
||||||
- degraded-mode capability floors
|
- degraded-mode capability floors
|
||||||
does_not_own:
|
does_not_own:
|
||||||
- live queue state outside Gitea truth
|
- live queue state outside Gitea truth
|
||||||
- launchd or loop process state
|
- launchd or loop process state
|
||||||
- ad hoc worktree history
|
- ad hoc worktree history
|
||||||
|
|
||||||
policy:
|
policy:
|
||||||
require_four_slots_for_critical_agents: true
|
require_four_slots_for_critical_agents: true
|
||||||
terminal_fallback_must_be_usable: true
|
terminal_fallback_must_be_usable: true
|
||||||
forbid_synchronized_fleet_degradation: true
|
forbid_synchronized_fleet_degradation: true
|
||||||
forbid_human_token_fallbacks: true
|
forbid_human_token_fallbacks: true
|
||||||
anti_correlation_rule: no two critical agents may share the same primary+fallback1 pair
|
anti_correlation_rule: no two critical agents may share the same primary+fallback1 pair
|
||||||
|
|
||||||
sensitive_control_surfaces:
|
sensitive_control_surfaces:
|
||||||
- SOUL.md
|
- SOUL.md
|
||||||
- config.yaml
|
- config.yaml
|
||||||
- deploy.sh
|
- deploy.sh
|
||||||
- tasks.py
|
- tasks.py
|
||||||
- playbooks/
|
- playbooks/
|
||||||
- cron/
|
- cron/
|
||||||
- memories/
|
- memories/
|
||||||
- skins/
|
- skins/
|
||||||
- training/
|
- training/
|
||||||
|
|
||||||
role_classes:
|
role_classes:
|
||||||
judgment:
|
judgment:
|
||||||
current_surfaces:
|
current_surfaces:
|
||||||
- playbooks/issue-triager.yaml
|
- playbooks/issue-triager.yaml
|
||||||
- playbooks/pr-reviewer.yaml
|
- playbooks/pr-reviewer.yaml
|
||||||
- playbooks/verified-logic.yaml
|
- playbooks/verified-logic.yaml
|
||||||
task_classes:
|
task_classes:
|
||||||
- issue-triage
|
- issue-triage
|
||||||
- queue-routing
|
- queue-routing
|
||||||
- pr-review
|
- pr-review
|
||||||
- proof-check
|
- proof-check
|
||||||
- governance-review
|
- governance-review
|
||||||
degraded_mode:
|
degraded_mode:
|
||||||
fallback2:
|
fallback2:
|
||||||
allowed:
|
allowed:
|
||||||
- classify backlog
|
- classify backlog
|
||||||
- summarize risk
|
- summarize risk
|
||||||
- produce draft routing plans
|
- produce draft routing plans
|
||||||
- leave bounded labels or comments with evidence
|
- leave bounded labels or comments with evidence
|
||||||
denied:
|
denied:
|
||||||
- merge pull requests
|
- merge pull requests
|
||||||
- close or rewrite governing issues or PRs
|
- close or rewrite governing issues or PRs
|
||||||
- mutate sensitive control surfaces
|
- mutate sensitive control surfaces
|
||||||
- bulk-reassign the fleet
|
- bulk-reassign the fleet
|
||||||
- silently change routing policy
|
- silently change routing policy
|
||||||
terminal:
|
terminal:
|
||||||
lane: report-and-route
|
lane: report-and-route
|
||||||
allowed:
|
allowed:
|
||||||
- classify backlog
|
- classify backlog
|
||||||
- summarize risk
|
- summarize risk
|
||||||
- produce draft routing artifacts
|
- produce draft routing artifacts
|
||||||
denied:
|
denied:
|
||||||
- merge pull requests
|
- merge pull requests
|
||||||
- bulk-reassign the fleet
|
- bulk-reassign the fleet
|
||||||
- mutate sensitive control surfaces
|
- mutate sensitive control surfaces
|
||||||
|
|
||||||
builder:
|
builder:
|
||||||
current_surfaces:
|
current_surfaces:
|
||||||
- playbooks/bug-fixer.yaml
|
- playbooks/bug-fixer.yaml
|
||||||
- playbooks/test-writer.yaml
|
- playbooks/test-writer.yaml
|
||||||
- playbooks/refactor-specialist.yaml
|
- playbooks/refactor-specialist.yaml
|
||||||
task_classes:
|
task_classes:
|
||||||
- bug-fix
|
- bug-fix
|
||||||
- test-writing
|
- test-writing
|
||||||
- refactor
|
- refactor
|
||||||
- bounded-docs-change
|
- bounded-docs-change
|
||||||
degraded_mode:
|
degraded_mode:
|
||||||
fallback2:
|
fallback2:
|
||||||
allowed:
|
allowed:
|
||||||
- reversible single-issue changes
|
- reversible single-issue changes
|
||||||
- narrow docs fixes
|
- narrow docs fixes
|
||||||
- test scaffolds and reproducers
|
- test scaffolds and reproducers
|
||||||
denied:
|
denied:
|
||||||
- cross-repo changes
|
- cross-repo changes
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
- merge or release actions
|
- merge or release actions
|
||||||
terminal:
|
terminal:
|
||||||
lane: narrow-patch
|
lane: narrow-patch
|
||||||
allowed:
|
allowed:
|
||||||
- single-issue small patch
|
- single-issue small patch
|
||||||
- reproducer test
|
- reproducer test
|
||||||
- docs-only repair
|
- docs-only repair
|
||||||
denied:
|
denied:
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
- multi-file architecture work
|
- multi-file architecture work
|
||||||
- irreversible actions
|
- irreversible actions
|
||||||
|
|
||||||
wolf_bulk:
|
wolf_bulk:
|
||||||
current_surfaces:
|
current_surfaces:
|
||||||
- docs/automation-inventory.md
|
- docs/automation-inventory.md
|
||||||
- FALSEWORK.md
|
- FALSEWORK.md
|
||||||
task_classes:
|
task_classes:
|
||||||
- docs-inventory
|
- docs-inventory
|
||||||
- log-summarization
|
- log-summarization
|
||||||
- queue-hygiene
|
- queue-hygiene
|
||||||
- repetitive-small-diff
|
- repetitive-small-diff
|
||||||
- research-sweep
|
- research-sweep
|
||||||
degraded_mode:
|
degraded_mode:
|
||||||
fallback2:
|
fallback2:
|
||||||
allowed:
|
allowed:
|
||||||
- gather evidence
|
- gather evidence
|
||||||
- refresh inventories
|
- refresh inventories
|
||||||
- summarize logs
|
- summarize logs
|
||||||
- propose labels or routes
|
- propose labels or routes
|
||||||
denied:
|
denied:
|
||||||
- multi-repo branch fanout
|
- multi-repo branch fanout
|
||||||
- mass agent assignment
|
- mass agent assignment
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
- irreversible queue mutation
|
- irreversible queue mutation
|
||||||
terminal:
|
terminal:
|
||||||
lane: gather-and-summarize
|
lane: gather-and-summarize
|
||||||
allowed:
|
allowed:
|
||||||
- inventory refresh
|
- inventory refresh
|
||||||
- evidence bundles
|
- evidence bundles
|
||||||
- summaries
|
- summaries
|
||||||
denied:
|
denied:
|
||||||
- multi-repo branch fanout
|
- multi-repo branch fanout
|
||||||
- mass agent assignment
|
- mass agent assignment
|
||||||
- sensitive control-surface edits
|
- sensitive control-surface edits
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
issue-triage: judgment
|
issue-triage: judgment
|
||||||
queue-routing: judgment
|
queue-routing: judgment
|
||||||
@@ -146,22 +139,20 @@ routing:
|
|||||||
queue-hygiene: wolf_bulk
|
queue-hygiene: wolf_bulk
|
||||||
repetitive-small-diff: wolf_bulk
|
repetitive-small-diff: wolf_bulk
|
||||||
research-sweep: wolf_bulk
|
research-sweep: wolf_bulk
|
||||||
|
|
||||||
promotion_rules:
|
promotion_rules:
|
||||||
- If a wolf/bulk task touches a sensitive control surface, promote it to judgment.
|
- If a wolf/bulk task touches a sensitive control surface, promote it to judgment.
|
||||||
- If a builder task expands beyond 5 files, architecture review, or multi-repo coordination, promote it to judgment.
|
- If a builder task expands beyond 5 files, architecture review, or multi-repo coordination, promote it to judgment.
|
||||||
- If a terminal lane cannot produce a usable artifact, the portfolio is invalid and must be redesigned before wiring.
|
- If a terminal lane cannot produce a usable artifact, the portfolio is invalid and must be redesigned before wiring.
|
||||||
|
|
||||||
agents:
|
agents:
|
||||||
triage-coordinator:
|
triage-coordinator:
|
||||||
role_class: judgment
|
role_class: judgment
|
||||||
critical: true
|
critical: true
|
||||||
current_playbooks:
|
current_playbooks:
|
||||||
- playbooks/issue-triager.yaml
|
- playbooks/issue-triager.yaml
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: anthropic
|
provider: kimi-coding
|
||||||
model: claude-opus-4-6
|
model: kimi-k2.5
|
||||||
lane: full-judgment
|
lane: full-judgment
|
||||||
fallback1:
|
fallback1:
|
||||||
provider: openai-codex
|
provider: openai-codex
|
||||||
@@ -177,19 +168,18 @@ agents:
|
|||||||
lane: report-and-route
|
lane: report-and-route
|
||||||
local_capable: true
|
local_capable: true
|
||||||
usable_output:
|
usable_output:
|
||||||
- backlog classification
|
- backlog classification
|
||||||
- routing draft
|
- routing draft
|
||||||
- risk summary
|
- risk summary
|
||||||
|
|
||||||
pr-reviewer:
|
pr-reviewer:
|
||||||
role_class: judgment
|
role_class: judgment
|
||||||
critical: true
|
critical: true
|
||||||
current_playbooks:
|
current_playbooks:
|
||||||
- playbooks/pr-reviewer.yaml
|
- playbooks/pr-reviewer.yaml
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: anthropic
|
provider: kimi-coding
|
||||||
model: claude-opus-4-6
|
model: kimi-k2.5
|
||||||
lane: full-review
|
lane: full-review
|
||||||
fallback1:
|
fallback1:
|
||||||
provider: gemini
|
provider: gemini
|
||||||
@@ -205,17 +195,16 @@ agents:
|
|||||||
lane: low-stakes-diff-summary
|
lane: low-stakes-diff-summary
|
||||||
local_capable: false
|
local_capable: false
|
||||||
usable_output:
|
usable_output:
|
||||||
- diff risk summary
|
- diff risk summary
|
||||||
- explicit uncertainty notes
|
- explicit uncertainty notes
|
||||||
- merge-block recommendation
|
- merge-block recommendation
|
||||||
|
|
||||||
builder-main:
|
builder-main:
|
||||||
role_class: builder
|
role_class: builder
|
||||||
critical: true
|
critical: true
|
||||||
current_playbooks:
|
current_playbooks:
|
||||||
- playbooks/bug-fixer.yaml
|
- playbooks/bug-fixer.yaml
|
||||||
- playbooks/test-writer.yaml
|
- playbooks/test-writer.yaml
|
||||||
- playbooks/refactor-specialist.yaml
|
- playbooks/refactor-specialist.yaml
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: openai-codex
|
provider: openai-codex
|
||||||
@@ -236,15 +225,14 @@ agents:
|
|||||||
lane: narrow-patch
|
lane: narrow-patch
|
||||||
local_capable: true
|
local_capable: true
|
||||||
usable_output:
|
usable_output:
|
||||||
- small patch
|
- small patch
|
||||||
- reproducer test
|
- reproducer test
|
||||||
- docs repair
|
- docs repair
|
||||||
|
|
||||||
wolf-sweeper:
|
wolf-sweeper:
|
||||||
role_class: wolf_bulk
|
role_class: wolf_bulk
|
||||||
critical: true
|
critical: true
|
||||||
current_world_state:
|
current_world_state:
|
||||||
- docs/automation-inventory.md
|
- docs/automation-inventory.md
|
||||||
portfolio:
|
portfolio:
|
||||||
primary:
|
primary:
|
||||||
provider: gemini
|
provider: gemini
|
||||||
@@ -264,21 +252,20 @@ agents:
|
|||||||
lane: gather-and-summarize
|
lane: gather-and-summarize
|
||||||
local_capable: true
|
local_capable: true
|
||||||
usable_output:
|
usable_output:
|
||||||
- inventory refresh
|
- inventory refresh
|
||||||
- evidence bundle
|
- evidence bundle
|
||||||
- summary comment
|
- summary comment
|
||||||
|
|
||||||
cross_checks:
|
cross_checks:
|
||||||
unique_primary_fallback1_pairs:
|
unique_primary_fallback1_pairs:
|
||||||
triage-coordinator:
|
triage-coordinator:
|
||||||
- anthropic/claude-opus-4-6
|
- kimi-coding/kimi-k2.5
|
||||||
- openai-codex/codex
|
- openai-codex/codex
|
||||||
pr-reviewer:
|
pr-reviewer:
|
||||||
- anthropic/claude-opus-4-6
|
- kimi-coding/kimi-k2.5
|
||||||
- gemini/gemini-2.5-pro
|
- gemini/gemini-2.5-pro
|
||||||
builder-main:
|
builder-main:
|
||||||
- openai-codex/codex
|
- openai-codex/codex
|
||||||
- kimi-coding/kimi-k2.5
|
- kimi-coding/kimi-k2.5
|
||||||
wolf-sweeper:
|
wolf-sweeper:
|
||||||
- gemini/gemini-2.5-flash
|
- gemini/gemini-2.5-flash
|
||||||
- groq/llama-3.3-70b-versatile
|
- groq/llama-3.3-70b-versatile
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ Three primary resources govern the fleet:
|
|||||||
| Hermes gateway | 500 MB | Primary gateway |
|
| Hermes gateway | 500 MB | Primary gateway |
|
||||||
| Hermes agents (x3) | ~560 MB total | Multiple sessions |
|
| Hermes agents (x3) | ~560 MB total | Multiple sessions |
|
||||||
| Ollama | ~20 MB base + model memory | Model loading varies |
|
| Ollama | ~20 MB base + model memory | Model loading varies |
|
||||||
| OpenClaw | 350 MB | Gateway process |
|
|
||||||
| Evennia (server+portal) | 56 MB | Game world |
|
| 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 |
|
| Gitea | 23/24 | 95.8% | GOOD |
|
||||||
| Hermes Gateway | 23/24 | 95.8% | GOOD |
|
| Hermes Gateway | 23/24 | 95.8% | GOOD |
|
||||||
| Ollama | 24/24 | 100.0% | GOOD |
|
| Ollama | 24/24 | 100.0% | GOOD |
|
||||||
| OpenClaw | 24/24 | 100.0% | GOOD |
|
|
||||||
| Evennia | 24/24 | 100.0% | GOOD |
|
| Evennia | 24/24 | 100.0% | GOOD |
|
||||||
| Hermes Agent | 21/24 | 87.5% | **CHECK** |
|
| 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-gateway": "pgrep -f 'hermes gateway' > /dev/null 2>/dev/null",
|
||||||
"hermes-agent": "pgrep -f 'hermes agent\\|hermes session' > /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",
|
"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",
|
"evennia": "pgrep -f 'evennia' > /dev/null 2>/dev/null",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def update_uptime(checks: dict):
|
|||||||
save(data)
|
save(data)
|
||||||
|
|
||||||
if new_milestones:
|
if new_milestones:
|
||||||
print(f" UPTIME MILESTONE: {','.join(str(m) + '%') for m in new_milestones}")
|
print(f" UPTIME MILESTONE: {','.join((str(m) + '%') for m in new_milestones)}")
|
||||||
print(f" Current uptime: {recent_ok:.1f}%")
|
print(f" Current uptime: {recent_ok:.1f}%")
|
||||||
|
|
||||||
return data["uptime"]
|
return data["uptime"]
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
| Hermes agent (s007) | 62032 | ~200MB | Session active since 10:20PM prev |
|
| Hermes agent (s007) | 62032 | ~200MB | Session active since 10:20PM prev |
|
||||||
| Hermes agent (s001) | 12072 | ~178MB | Session active since Sun 6PM |
|
| Hermes agent (s001) | 12072 | ~178MB | Session active since Sun 6PM |
|
||||||
| Ollama | 71466 | ~20MB | /opt/homebrew/opt/ollama/bin/ollama serve |
|
| 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 |
|
| Crucible MCP (x4) | multiple | ~10-69MB each | MCP server instances |
|
||||||
| Evennia Server | 66433 | ~49MB | Sun 10PM start, port 4000 |
|
| Evennia Server | 66433 | ~49MB | Sun 10PM start, port 4000 |
|
||||||
| Evennia Portal | 66423 | ~7MB | Sun 10PM start, port 4001 |
|
| Evennia Portal | 66423 | ~7MB | Sun 10PM start, port 4001 |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: forge-ci-${{ gitea.ref }}
|
group: forge-ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,40 +18,21 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: "uv.lock"
|
|
||||||
|
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
run: uv python install 3.11
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: Install package
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv venv .venv --python 3.11
|
pip install pytest pyyaml
|
||||||
source .venv/bin/activate
|
|
||||||
uv pip install -e ".[all,dev]"
|
|
||||||
|
|
||||||
- name: Smoke tests
|
- name: Smoke tests
|
||||||
run: |
|
run: python scripts/smoke_test.py
|
||||||
source .venv/bin/activate
|
|
||||||
python scripts/smoke_test.py
|
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_KEY: ""
|
OPENROUTER_API_KEY: ""
|
||||||
OPENAI_API_KEY: ""
|
OPENAI_API_KEY: ""
|
||||||
NOUS_API_KEY: ""
|
NOUS_API_KEY: ""
|
||||||
|
|
||||||
- name: Syntax guard
|
- name: Syntax guard
|
||||||
run: |
|
run: python scripts/syntax_guard.py
|
||||||
source .venv/bin/activate
|
|
||||||
python scripts/syntax_guard.py
|
|
||||||
|
|
||||||
- name: Green-path E2E
|
|
||||||
run: |
|
|
||||||
source .venv/bin/activate
|
|
||||||
python -m pytest tests/test_green_path_e2e.py -q --tb=short
|
|
||||||
env:
|
|
||||||
OPENROUTER_API_KEY: ""
|
|
||||||
OPENAI_API_KEY: ""
|
|
||||||
NOUS_API_KEY: ""
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install papermill jupytext nbformat
|
pip install papermill jupytext nbformat ipykernel
|
||||||
python -m ipykernel install --user --name python3
|
python -m ipykernel install --user --name python3
|
||||||
|
|
||||||
- name: Execute system health notebook
|
- name: Execute system health notebook
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def check_core_deps() -> CheckResult:
|
|||||||
"""Verify that hermes core Python packages are importable."""
|
"""Verify that hermes core Python packages are importable."""
|
||||||
required = [
|
required = [
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"kimi-coding",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"yaml",
|
"yaml",
|
||||||
"rich",
|
"rich",
|
||||||
@@ -206,8 +206,8 @@ def check_env_vars() -> CheckResult:
|
|||||||
"""Check that at least one LLM provider key is configured."""
|
"""Check that at least one LLM provider key is configured."""
|
||||||
provider_keys = [
|
provider_keys = [
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"ANTHROPIC_API_KEY",
|
"KIMI_API_KEY",
|
||||||
"ANTHROPIC_TOKEN",
|
# "ANTHROPIC_TOKEN", # BANNED
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
"GLM_API_KEY",
|
"GLM_API_KEY",
|
||||||
"KIMI_API_KEY",
|
"KIMI_API_KEY",
|
||||||
@@ -225,7 +225,7 @@ def check_env_vars() -> CheckResult:
|
|||||||
passed=False,
|
passed=False,
|
||||||
message="No LLM provider API key found",
|
message="No LLM provider API key found",
|
||||||
fix_hint=(
|
fix_hint=(
|
||||||
"Set at least one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY "
|
"Set at least one of: OPENROUTER_API_KEY, KIMI_API_KEY, OPENAI_API_KEY "
|
||||||
"in ~/.hermes/.env or your shell."
|
"in ~/.hermes/.env or your shell."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
- "traefik.http.routers.matrix-client.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.matrix-client.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.matrix-client.entrypoints=websecure"
|
- "traefik.http.routers.matrix-client.entrypoints=websecure"
|
||||||
- "traefik.http.services.matrix-client.loadbalancer.server.port=6167"
|
- "traefik.http.services.matrix-client.loadbalancer.server.port=6167"
|
||||||
|
|
||||||
# Federation (TCP 8448) - direct or via Traefik TCP entrypoint
|
# Federation (TCP 8448) - direct or via Traefik TCP entrypoint
|
||||||
# Option A: Direct host port mapping
|
# Option A: Direct host port mapping
|
||||||
# Option B: Traefik TCP router (requires Traefik federation entrypoint)
|
# Option B: Traefik TCP router (requires Traefik federation entrypoint)
|
||||||
|
|||||||
331
pipeline/nightly_scheduler.py
Executable file
331
pipeline/nightly_scheduler.py
Executable file
@@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
nightly_scheduler.py — Nightly Pipeline Scheduler
|
||||||
|
|
||||||
|
Auto-starts batch pipelines when inference is available, respecting
|
||||||
|
priority ordering, token budgets, and peak-hour pausing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 nightly_scheduler.py # run scheduler
|
||||||
|
python3 nightly_scheduler.py --check # dry-run: show what would start
|
||||||
|
python3 nightly_scheduler.py --status # show pipeline status
|
||||||
|
python3 nightly_scheduler.py --reset # reset daily budget
|
||||||
|
|
||||||
|
Crontab:
|
||||||
|
# Run every 30 minutes during off-peak hours (10pm-6am)
|
||||||
|
*/30 22-5 * * * cd /path/to/timmy-config && python3 pipeline/nightly_scheduler.py >> ~/.hermes/pipeline-logs/nightly.log 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
STATE_FILE = Path.home() / ".hermes" / "pipeline_state.json"
|
||||||
|
LOG_DIR = Path.home() / ".hermes" / "pipeline-logs"
|
||||||
|
DAILY_TOKEN_BUDGET = 5_000_000 # 5M tokens per day
|
||||||
|
PEAK_HOURS = list(range(8, 22)) # 8am-10pm = peak interactive usage
|
||||||
|
CHECK_INTERVAL = 1800 # 30 minutes
|
||||||
|
|
||||||
|
INFERENCE_ENDPOINTS = [
|
||||||
|
{"name": "local_ollama", "url": "http://localhost:11434/v1/models", "type": "local"},
|
||||||
|
{"name": "runpod", "url": "https://8lfr3j47a5r3gn-11434.proxy.runpod.net/v1/models", "type": "gpu"},
|
||||||
|
{"name": "openrouter", "url": "https://openrouter.ai/api/v1/models", "type": "cloud"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pipeline priority order (highest first)
|
||||||
|
PIPELINE_PRIORITY = [
|
||||||
|
{"name": "playground_factory", "script": "pipeline/playground_factory.py", "priority": 1},
|
||||||
|
{"name": "training_factory", "script": "pipeline/training_factory.py", "priority": 2},
|
||||||
|
{"name": "knowledge_mine", "script": "pipeline/knowledge_mine.py", "priority": 3},
|
||||||
|
{"name": "adversary", "script": "pipeline/adversary_runner.py", "priority": 4},
|
||||||
|
{"name": "codebase_genome", "script": "pipeline/codebase_genome.py", "priority": 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Dependency rules: some pipelines only start after others are running
|
||||||
|
DEPENDENCY_RULES = {
|
||||||
|
"playground_factory": [], # no deps, start immediately
|
||||||
|
"training_factory": [], # no deps, start in parallel
|
||||||
|
"knowledge_mine": ["training_factory"], # start after training is running
|
||||||
|
"adversary": ["knowledge_mine"], # start after knowledge is halfway
|
||||||
|
"codebase_genome": [], # continuous, one repo per night
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
"""Load pipeline state from disk."""
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {
|
||||||
|
"last_run": None,
|
||||||
|
"daily_tokens_used": 0,
|
||||||
|
"budget_reset_date": None,
|
||||||
|
"pipelines": {},
|
||||||
|
"active_sessions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state):
|
||||||
|
"""Save pipeline state to disk."""
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def check_provider(endpoint):
|
||||||
|
"""Check if an inference provider is available."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(endpoint["url"], headers={"Authorization": "Bearer ollama"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_providers():
|
||||||
|
"""Check all inference endpoints and return available ones."""
|
||||||
|
available = []
|
||||||
|
for ep in INFERENCE_ENDPOINTS:
|
||||||
|
if check_provider(ep):
|
||||||
|
available.append(ep["name"])
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def is_peak_hours():
|
||||||
|
"""Check if current time is during peak interactive usage."""
|
||||||
|
now = datetime.now()
|
||||||
|
return now.hour in PEAK_HOURS
|
||||||
|
|
||||||
|
|
||||||
|
def check_token_budget(state):
|
||||||
|
"""Check if daily token budget allows starting new work."""
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
if state.get("budget_reset_date") != today:
|
||||||
|
# New day, reset budget
|
||||||
|
state["daily_tokens_used"] = 0
|
||||||
|
state["budget_reset_date"] = today
|
||||||
|
save_state(state)
|
||||||
|
return state["daily_tokens_used"] < DAILY_TOKEN_BUDGET
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline_status(state, pipeline_name):
|
||||||
|
"""Get the status of a specific pipeline."""
|
||||||
|
return state.get("pipelines", {}).get(pipeline_name, {
|
||||||
|
"status": "not_started",
|
||||||
|
"last_run": None,
|
||||||
|
"last_success": None,
|
||||||
|
"progress": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies(state, pipeline_name):
|
||||||
|
"""Check if pipeline dependencies are satisfied."""
|
||||||
|
deps = DEPENDENCY_RULES.get(pipeline_name, [])
|
||||||
|
for dep in deps:
|
||||||
|
dep_status = get_pipeline_status(state, dep)
|
||||||
|
if dep_status["status"] not in ("running", "completed"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def start_pipeline(pipeline, state, dry_run=False):
|
||||||
|
"""Start a pipeline process."""
|
||||||
|
name = pipeline["name"]
|
||||||
|
script = pipeline["script"]
|
||||||
|
|
||||||
|
log(f"Starting pipeline: {name}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
log(f" DRY RUN — would run: python3 {script}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if script exists
|
||||||
|
script_path = Path(script)
|
||||||
|
if not script_path.exists():
|
||||||
|
log(f" Script not found: {script_path}")
|
||||||
|
# Update state anyway so we track the attempt
|
||||||
|
state["pipelines"][name] = {
|
||||||
|
"status": "script_missing",
|
||||||
|
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"progress": 0,
|
||||||
|
}
|
||||||
|
save_state(state)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Run the pipeline script
|
||||||
|
import subprocess
|
||||||
|
log_dir = LOG_DIR / name
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["python3", str(script_path)],
|
||||||
|
stdout=open(log_file, "w"),
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=str(Path(script).parent.parent),
|
||||||
|
)
|
||||||
|
|
||||||
|
state["pipelines"][name] = {
|
||||||
|
"status": "running",
|
||||||
|
"pid": proc.pid,
|
||||||
|
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"log_file": str(log_file),
|
||||||
|
"progress": 0,
|
||||||
|
}
|
||||||
|
save_state(state)
|
||||||
|
log(f" Started PID {proc.pid}, log: {log_file}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log(f" Failed to start: {e}")
|
||||||
|
state["pipelines"][name] = {
|
||||||
|
"status": "failed",
|
||||||
|
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
save_state(state)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_running_pipelines(state):
|
||||||
|
"""Check status of running pipelines and update state."""
|
||||||
|
import subprocess
|
||||||
|
for name, info in state.get("pipelines", {}).items():
|
||||||
|
if info.get("status") == "running":
|
||||||
|
pid = info.get("pid")
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0) # Check if process exists
|
||||||
|
except ProcessLookupError:
|
||||||
|
# Process finished
|
||||||
|
info["status"] = "completed"
|
||||||
|
info["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
log(f"Pipeline {name} completed (PID {pid} exited)")
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduler(dry_run=False, check_only=False):
|
||||||
|
"""Main scheduler loop."""
|
||||||
|
state = load_state()
|
||||||
|
|
||||||
|
log("=" * 50)
|
||||||
|
log(f"Pipeline Scheduler — {datetime.now().isoformat()}")
|
||||||
|
log(f"Mode: {'CHECK' if check_only else 'DRY RUN' if dry_run else 'LIVE'}")
|
||||||
|
|
||||||
|
# Check peak hours
|
||||||
|
if is_peak_hours():
|
||||||
|
log("Peak hours detected. Pausing pipeline starts.")
|
||||||
|
log("Pipelines will resume at 10pm.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check token budget
|
||||||
|
if not check_token_budget(state):
|
||||||
|
log(f"Daily token budget exhausted ({state['daily_tokens_used']}/{DAILY_TOKEN_BUDGET})")
|
||||||
|
return
|
||||||
|
log(f"Token budget: {state['daily_tokens_used']}/{DAILY_TOKEN_BUDGET}")
|
||||||
|
|
||||||
|
# Check providers
|
||||||
|
providers = get_available_providers()
|
||||||
|
if not providers:
|
||||||
|
log("No inference providers available. Skipping.")
|
||||||
|
return
|
||||||
|
log(f"Available providers: {', '.join(providers)}")
|
||||||
|
|
||||||
|
# Check running pipelines
|
||||||
|
check_running_pipelines(state)
|
||||||
|
|
||||||
|
# Find next pipeline to start
|
||||||
|
started = 0
|
||||||
|
for pipeline in sorted(PIPELINE_PRIORITY, key=lambda p: p["priority"]):
|
||||||
|
name = pipeline["name"]
|
||||||
|
status = get_pipeline_status(state, name)
|
||||||
|
|
||||||
|
# Skip if already running or completed
|
||||||
|
if status["status"] in ("running", "completed"):
|
||||||
|
log(f" {name}: {status['status']} (skipping)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
if not check_dependencies(state, name):
|
||||||
|
deps = DEPENDENCY_RULES.get(name, [])
|
||||||
|
log(f" {name}: waiting for dependencies: {deps}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start the pipeline
|
||||||
|
if check_only:
|
||||||
|
log(f" {name}: READY to start (priority {pipeline['priority']})")
|
||||||
|
else:
|
||||||
|
if start_pipeline(pipeline, state, dry_run):
|
||||||
|
started += 1
|
||||||
|
# Only start one pipeline per run to avoid overload
|
||||||
|
if started >= 1:
|
||||||
|
log("Started 1 pipeline. Will check again next cycle.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if started == 0 and not check_only:
|
||||||
|
log("No pipelines to start. All are running, completed, or blocked.")
|
||||||
|
|
||||||
|
log("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def show_status():
|
||||||
|
"""Show current pipeline status."""
|
||||||
|
state = load_state()
|
||||||
|
print(f"\nPipeline Status — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
print(f"Token budget: {state.get('daily_tokens_used', 0)}/{DAILY_TOKEN_BUDGET}")
|
||||||
|
print(f"Last run: {state.get('last_run', 'never')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for pipeline in sorted(PIPELINE_PRIORITY, key=lambda p: p["priority"]):
|
||||||
|
name = pipeline["name"]
|
||||||
|
status = get_pipeline_status(state, name)
|
||||||
|
st = status["status"]
|
||||||
|
icon = {"running": "●", "completed": "✓", "failed": "✗", "not_started": "○", "script_missing": "?"}.get(st, "?")
|
||||||
|
print(f" {icon} {name:25} {st:15} last={(status.get('last_run') or 'never')[:19]}")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_budget():
|
||||||
|
"""Reset daily token budget."""
|
||||||
|
state = load_state()
|
||||||
|
state["daily_tokens_used"] = 0
|
||||||
|
state["budget_reset_date"] = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
save_state(state)
|
||||||
|
print("Budget reset.")
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
"""Log to stdout and file."""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
line = f"[{timestamp}] {msg}"
|
||||||
|
print(line)
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = LOG_DIR / "nightly.log"
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="Nightly Pipeline Scheduler")
|
||||||
|
parser.add_argument("--check", action="store_true", help="Dry-run: show what would start")
|
||||||
|
parser.add_argument("--status", action="store_true", help="Show pipeline status")
|
||||||
|
parser.add_argument("--reset", action="store_true", help="Reset daily token budget")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Dry-run mode")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
show_status()
|
||||||
|
elif args.reset:
|
||||||
|
reset_budget()
|
||||||
|
else:
|
||||||
|
run_scheduler(dry_run=args.dry_run or args.check, check_only=args.check)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
reproduces the bug, then fixes the code, then verifies.
|
reproduces the bug, then fixes the code, then verifies.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|
||||||
|
|||||||
166
playbooks/fleet-guardrails.yaml
Normal file
166
playbooks/fleet-guardrails.yaml
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# fleet-guardrails.yaml
|
||||||
|
# =====================
|
||||||
|
# Enforceable behaviour boundaries for every agent in the Timmy fleet.
|
||||||
|
# Consumed by task_gate.py (pre/post checks) and the orchestrator's
|
||||||
|
# dispatch loop. Every rule here is testable — no aspirational prose.
|
||||||
|
#
|
||||||
|
# Ref: SOUL.md "grounding before generation", Five Wisdoms #345
|
||||||
|
|
||||||
|
name: fleet-guardrails
|
||||||
|
version: "1.0.0"
|
||||||
|
description: >
|
||||||
|
Behaviour constraints that apply to ALL agents regardless of role.
|
||||||
|
These are the non-negotiable rules that task_gate.py enforces
|
||||||
|
before an agent may pick up work and after it claims completion.
|
||||||
|
|
||||||
|
# ─── UNIVERSAL CONSTRAINTS ───────────────────────────────────────
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
|
||||||
|
# 1. Lane discipline — agents must stay in their lane
|
||||||
|
lane_enforcement:
|
||||||
|
enabled: true
|
||||||
|
source: playbooks/agent-lanes.json
|
||||||
|
on_violation: block_and_notify
|
||||||
|
description: >
|
||||||
|
An agent may only pick up issues tagged for its lane.
|
||||||
|
Cross-lane work requires explicit Timmy approval via
|
||||||
|
issue comment containing 'LANE_OVERRIDE: <agent>'.
|
||||||
|
|
||||||
|
# 2. Branch hygiene — no orphan branches
|
||||||
|
branch_hygiene:
|
||||||
|
enabled: true
|
||||||
|
max_branches_per_agent: 3
|
||||||
|
stale_branch_days: 7
|
||||||
|
naming_pattern: "{agent}/{issue_number}-{slug}"
|
||||||
|
on_violation: warn_then_block
|
||||||
|
description: >
|
||||||
|
Agents must follow branch naming conventions and clean up
|
||||||
|
after merge. No agent may have more than 3 active branches.
|
||||||
|
|
||||||
|
# 3. Issue ownership — no silent takeovers
|
||||||
|
issue_ownership:
|
||||||
|
enabled: true
|
||||||
|
require_assignment_before_work: true
|
||||||
|
max_concurrent_issues: 2
|
||||||
|
on_violation: block_and_notify
|
||||||
|
description: >
|
||||||
|
An agent must be assigned to an issue before creating a
|
||||||
|
branch or PR. No agent may work on more than 2 issues
|
||||||
|
simultaneously to prevent context-switching waste.
|
||||||
|
|
||||||
|
# 4. PR quality — minimum bar before review
|
||||||
|
pr_quality:
|
||||||
|
enabled: true
|
||||||
|
require_linked_issue: true
|
||||||
|
require_passing_ci: true
|
||||||
|
max_files_changed: 30
|
||||||
|
max_diff_lines: 2000
|
||||||
|
require_description: true
|
||||||
|
min_description_length: 50
|
||||||
|
on_violation: block_merge
|
||||||
|
description: >
|
||||||
|
Every PR must link an issue, pass CI, have a meaningful
|
||||||
|
description, and stay within scope. Giant PRs get rejected.
|
||||||
|
|
||||||
|
# 5. Grounding before generation — SOUL.md compliance
|
||||||
|
grounding:
|
||||||
|
enabled: true
|
||||||
|
require_issue_read_before_branch: true
|
||||||
|
require_existing_code_review: true
|
||||||
|
require_soul_md_check: true
|
||||||
|
soul_md_path: SOUL.md
|
||||||
|
on_violation: block_and_notify
|
||||||
|
description: >
|
||||||
|
Before writing any code, the agent must demonstrate it has
|
||||||
|
read the issue, reviewed relevant existing code, and checked
|
||||||
|
SOUL.md for applicable doctrine. No speculative generation.
|
||||||
|
|
||||||
|
# 6. Completion integrity — no phantom completions
|
||||||
|
completion_checks:
|
||||||
|
enabled: true
|
||||||
|
require_test_evidence: true
|
||||||
|
require_ci_green: true
|
||||||
|
require_diff_matches_issue: true
|
||||||
|
require_no_unrelated_changes: true
|
||||||
|
on_violation: revert_and_notify
|
||||||
|
description: >
|
||||||
|
Post-task gate verifies the work actually addresses the
|
||||||
|
issue. Agents cannot close issues without evidence.
|
||||||
|
Unrelated changes in a PR trigger automatic rejection.
|
||||||
|
|
||||||
|
# 7. Communication discipline — no noise
|
||||||
|
communication:
|
||||||
|
enabled: true
|
||||||
|
max_comments_per_issue: 10
|
||||||
|
require_structured_updates: true
|
||||||
|
update_format: "status | what_changed | what_blocked | next_step"
|
||||||
|
prohibit_empty_updates: true
|
||||||
|
on_violation: warn
|
||||||
|
description: >
|
||||||
|
Issue comments must be structured and substantive.
|
||||||
|
Status-only comments without content are rejected.
|
||||||
|
Agents should update, not narrate.
|
||||||
|
|
||||||
|
# 8. Resource awareness — no runaway costs
|
||||||
|
resource_limits:
|
||||||
|
enabled: true
|
||||||
|
max_api_calls_per_task: 100
|
||||||
|
max_llm_tokens_per_task: 500000
|
||||||
|
max_task_duration_minutes: 60
|
||||||
|
on_violation: kill_and_notify
|
||||||
|
description: >
|
||||||
|
Hard limits on compute per task. If an agent hits these
|
||||||
|
limits, the task is killed and flagged for human review.
|
||||||
|
Prevents infinite loops and runaway API spending.
|
||||||
|
|
||||||
|
# ─── ESCALATION POLICY ───────────────────────────────────────────
|
||||||
|
|
||||||
|
escalation:
|
||||||
|
channels:
|
||||||
|
- gitea_issue_comment
|
||||||
|
- discord_webhook
|
||||||
|
severity_levels:
|
||||||
|
warn:
|
||||||
|
action: post_comment
|
||||||
|
notify: agent_only
|
||||||
|
block:
|
||||||
|
action: prevent_action
|
||||||
|
notify: agent_and_orchestrator
|
||||||
|
block_and_notify:
|
||||||
|
action: prevent_action
|
||||||
|
notify: agent_orchestrator_and_timmy
|
||||||
|
kill_and_notify:
|
||||||
|
action: terminate_task
|
||||||
|
notify: all_including_alexander
|
||||||
|
revert_and_notify:
|
||||||
|
action: revert_changes
|
||||||
|
notify: agent_orchestrator_and_timmy
|
||||||
|
|
||||||
|
# ─── AUDIT TRAIL ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
audit:
|
||||||
|
enabled: true
|
||||||
|
log_path: logs/guardrail-violations.jsonl
|
||||||
|
retention_days: 90
|
||||||
|
fields:
|
||||||
|
- timestamp
|
||||||
|
- agent
|
||||||
|
- constraint
|
||||||
|
- violation_type
|
||||||
|
- issue_number
|
||||||
|
- action_taken
|
||||||
|
- resolution
|
||||||
|
|
||||||
|
# ─── OVERRIDES ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
# Only Timmy or Alexander can override guardrails
|
||||||
|
authorized_overriders:
|
||||||
|
- Timmy
|
||||||
|
- Alexander
|
||||||
|
override_mechanism: >
|
||||||
|
Post a comment on the issue with the format:
|
||||||
|
GUARDRAIL_OVERRIDE: <constraint_name> REASON: <explanation>
|
||||||
|
override_expiry_hours: 24
|
||||||
|
require_post_override_review: true
|
||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
agents. Decomposes large issues into smaller ones.
|
agents. Decomposes large issues into smaller ones.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 20
|
max_turns: 20
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ system_prompt: |
|
|||||||
- codex-agent: cleanup, migration verification, dead-code removal, repo-boundary enforcement, workflow hardening
|
- 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
|
- 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
|
- manus: bounded support tasks, moderate-scope implementation, follow-through on already-scoped work
|
||||||
- claude: hard refactors, broad multi-file implementation, test-heavy changes after the scope is made precise
|
- kimi: hard refactors, broad multi-file implementation, test-heavy changes after the scope is made precise
|
||||||
- gemini: frontier architecture, research-heavy prototypes, long-range design thinking when a concrete implementation owner is not yet obvious
|
- 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
|
- 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
|
5. Decompose any issue touching >5 files or crossing repo boundaries into smaller issues before assigning execution
|
||||||
@@ -63,6 +63,6 @@ system_prompt: |
|
|||||||
- Search for existing issues or PRs covering the same request before assigning anything. If a likely duplicate exists, link it and do not create or route duplicate work.
|
- 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 open-ended ideation to implementation agents.
|
||||||
- Do not assign routine backlog maintenance to Timmy.
|
- Do not assign routine backlog maintenance to Timmy.
|
||||||
- Do not assign wide speculative backlog generation to codex-agent, groq, manus, or claude.
|
- Do not assign wide speculative backlog generation to codex-agent, groq, or manus.
|
||||||
- Route archive/history/context-digestion work to ezra or KimiClaw before routing it to a builder.
|
- Route 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.
|
- Route “who should do this?” and “what is the next move?” questions to allegro.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
comments on problems. The merge bot replacement.
|
comments on problems. The merge bot replacement.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 20
|
max_turns: 20
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
Well-scoped: 1-3 files per task, clear acceptance criteria.
|
Well-scoped: 1-3 files per task, clear acceptance criteria.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
dependency issues. Files findings as Gitea issues.
|
dependency issues. Files findings as Gitea issues.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-opus-4-6
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 40
|
max_turns: 40
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: >
|
|||||||
writes meaningful tests, verifies they pass.
|
writes meaningful tests, verifies they pass.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ description: >
|
|||||||
and consistency verification.
|
and consistency verification.
|
||||||
|
|
||||||
model:
|
model:
|
||||||
preferred: claude-opus-4-6
|
preferred: kimi-k2.5
|
||||||
fallback: claude-sonnet-4-20250514
|
fallback: google/gemini-2.5-pro
|
||||||
max_turns: 12
|
max_turns: 12
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
|
|
||||||
|
|||||||
60
scripts/README.md
Normal file
60
scripts/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Gemini Sovereign Infrastructure Suite
|
||||||
|
|
||||||
|
This directory contains the core systems of the Gemini Sovereign Infrastructure, designed to systematize fleet operations, governance, and architectural integrity.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Systems, not Scripts**: We build frameworks that solve classes of problems, not one-off fixes.
|
||||||
|
2. **Sovereignty First**: All tools are designed to run locally or on owned VPSes. No cloud dependencies.
|
||||||
|
3. **Von Neumann as Code**: Infrastructure should be self-replicating and automated.
|
||||||
|
4. **Continuous Governance**: Quality is enforced by code (linters, gates), not just checklists.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
### [OPS] Provisioning & Fleet Management
|
||||||
|
|
||||||
|
- **`provision_wizard.py`**: Automates the creation of a new Wizard node from zero.
|
||||||
|
- Creates DigitalOcean droplet.
|
||||||
|
- Installs and builds `llama.cpp`.
|
||||||
|
- Downloads GGUF models.
|
||||||
|
- Sets up `systemd` services and health checks.
|
||||||
|
- **`fleet_llama.py`**: Unified management of `llama-server` instances across the fleet.
|
||||||
|
- `status`: Real-time health and model monitoring.
|
||||||
|
- `restart`: Remote service restart via SSH.
|
||||||
|
- `swap`: Hot-swapping GGUF models on remote nodes.
|
||||||
|
- **`skill_installer.py`**: Packages and deploys Hermes skills to remote wizards.
|
||||||
|
- **`model_eval.py`**: Benchmarks GGUF models for speed and quality before deployment.
|
||||||
|
- **`phase_tracker.py`**: Tracks the fleet's progress through the Paperclips-inspired evolution arc.
|
||||||
|
- **`cross_repo_test.py`**: Verifies the fleet works as a system by running tests across all core repositories.
|
||||||
|
- **`self_healing.py`**: Auto-detects and fixes common failures across the fleet.
|
||||||
|
- **`agent_dispatch.py`**: Unified framework for tasking agents across the fleet.
|
||||||
|
- **`telemetry.py`**: Operational visibility without cloud dependencies.
|
||||||
|
- **`gitea_webhook_handler.py`**: Handles real-time events from Gitea to coordinate fleet actions.
|
||||||
|
|
||||||
|
### [ARCH] Governance & Architecture
|
||||||
|
|
||||||
|
- **`architecture_linter_v2.py`**: Automated enforcement of architectural boundaries.
|
||||||
|
- Enforces sidecar boundaries (no sovereign code in `hermes-agent`).
|
||||||
|
- Prevents hardcoded IPs and committed secrets.
|
||||||
|
- Ensures `SOUL.md` and `README.md` standards.
|
||||||
|
- **`adr_manager.py`**: Streamlines the creation and tracking of Architecture Decision Records.
|
||||||
|
- `new`: Scaffolds a new ADR from a template.
|
||||||
|
- `list`: Provides a chronological view of architectural evolution.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Most tools require `DIGITALOCEAN_TOKEN` and SSH access to the fleet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Provision a new node
|
||||||
|
python3 scripts/provision_wizard.py --name fenrir --model qwen2.5-coder-7b
|
||||||
|
|
||||||
|
# Check fleet status
|
||||||
|
python3 scripts/fleet_llama.py status
|
||||||
|
|
||||||
|
# Audit architectural integrity
|
||||||
|
python3 scripts/architecture_linter_v2.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
*Built by Gemini — The Builder, The Systematizer, The Force Multiplier.*
|
||||||
151
scripts/a11y-check.js
Normal file
151
scripts/a11y-check.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// a11y-check.js — Automated accessibility audit script for Foundation web properties
|
||||||
|
// Run in browser console or via Playwright/Puppeteer
|
||||||
|
//
|
||||||
|
// Usage: Paste into DevTools console, or include in automated test suite.
|
||||||
|
// Returns a JSON object with pass/fail for WCAG 2.1 AA checks.
|
||||||
|
|
||||||
|
(function a11yAudit() {
|
||||||
|
const results = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: window.location.href,
|
||||||
|
title: document.title,
|
||||||
|
violations: [],
|
||||||
|
passes: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 2.4.1 Skip Navigation ---
|
||||||
|
const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-nav, .skip-link');
|
||||||
|
if (skipLink) {
|
||||||
|
results.passes.push({ rule: '2.4.1', name: 'Skip Navigation', detail: 'Skip link found' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '2.4.1', name: 'Skip Navigation', severity: 'medium', detail: 'No skip-to-content link found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.3.1 / 3.3.2 Form Labels ---
|
||||||
|
const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => {
|
||||||
|
if (el.type === 'hidden') return false;
|
||||||
|
const id = el.id;
|
||||||
|
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
|
||||||
|
const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
|
||||||
|
const hasTitle = el.getAttribute('title');
|
||||||
|
const hasPlaceholder = el.getAttribute('placeholder'); // placeholder alone is NOT sufficient
|
||||||
|
return !hasLabel && !hasAriaLabel && !hasTitle;
|
||||||
|
});
|
||||||
|
if (unlabeledInputs.length === 0) {
|
||||||
|
results.passes.push({ rule: '3.3.2', name: 'Form Labels', detail: 'All inputs have labels' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({
|
||||||
|
rule: '3.3.2',
|
||||||
|
name: 'Form Labels',
|
||||||
|
severity: 'high',
|
||||||
|
detail: `${unlabeledInputs.length} inputs without programmatic labels`,
|
||||||
|
elements: unlabeledInputs.map(el => ({ tag: el.tagName, type: el.type, name: el.name, id: el.id }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.4.3 Contrast (heuristic: very light text colors) ---
|
||||||
|
const lowContrast = Array.from(document.querySelectorAll('p, span, a, li, td, th, label, small, footer *')).filter(el => {
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const color = style.color;
|
||||||
|
// Check for very light RGB values (r/g/b < 120)
|
||||||
|
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||||
|
if (!match) return false;
|
||||||
|
const [, r, g, b] = match.map(Number);
|
||||||
|
return r < 120 && g < 120 && b < 120 && (r + g + b) < 200;
|
||||||
|
});
|
||||||
|
if (lowContrast.length === 0) {
|
||||||
|
results.passes.push({ rule: '1.4.3', name: 'Contrast', detail: 'No obviously low-contrast text found' });
|
||||||
|
} else {
|
||||||
|
results.warnings.push({ rule: '1.4.3', name: 'Contrast', detail: `${lowContrast.length} elements with potentially low contrast (manual verification needed)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.3.1 Heading Hierarchy ---
|
||||||
|
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => ({
|
||||||
|
level: parseInt(h.tagName[1]),
|
||||||
|
text: h.textContent.trim().substring(0, 80)
|
||||||
|
}));
|
||||||
|
let headingIssues = [];
|
||||||
|
let lastLevel = 0;
|
||||||
|
for (const h of headings) {
|
||||||
|
if (h.level > lastLevel + 1 && lastLevel > 0) {
|
||||||
|
headingIssues.push(`Skipped h${lastLevel} to h${h.level}: "${h.text}"`);
|
||||||
|
}
|
||||||
|
lastLevel = h.level;
|
||||||
|
}
|
||||||
|
if (headingIssues.length === 0 && headings.length > 0) {
|
||||||
|
results.passes.push({ rule: '1.3.1', name: 'Heading Hierarchy', detail: `${headings.length} headings, proper nesting` });
|
||||||
|
} else if (headingIssues.length > 0) {
|
||||||
|
results.violations.push({ rule: '1.3.1', name: 'Heading Hierarchy', severity: 'low', detail: headingIssues.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1.3.1 Landmarks ---
|
||||||
|
const landmarks = {
|
||||||
|
main: document.querySelectorAll('main, [role="main"]').length,
|
||||||
|
nav: document.querySelectorAll('nav, [role="navigation"]').length,
|
||||||
|
banner: document.querySelectorAll('header, [role="banner"]').length,
|
||||||
|
contentinfo: document.querySelectorAll('footer, [role="contentinfo"]').length
|
||||||
|
};
|
||||||
|
if (landmarks.main > 0) {
|
||||||
|
results.passes.push({ rule: '1.3.1', name: 'Main Landmark', detail: 'Found' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '1.3.1', name: 'Main Landmark', severity: 'medium', detail: 'No <main> or role="main" found' });
|
||||||
|
}
|
||||||
|
if (landmarks.banner === 0) {
|
||||||
|
results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No <header> or role="banner" found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.3.1 Required Fields ---
|
||||||
|
const requiredInputs = document.querySelectorAll('input[required], input[aria-required="true"]');
|
||||||
|
if (requiredInputs.length > 0) {
|
||||||
|
results.passes.push({ rule: '3.3.1', name: 'Required Fields', detail: `${requiredInputs.length} inputs marked as required` });
|
||||||
|
} else {
|
||||||
|
const visualRequired = document.querySelector('.required, [class*="required"], label .text-danger');
|
||||||
|
if (visualRequired) {
|
||||||
|
results.warnings.push({ rule: '3.3.1', name: 'Required Fields', detail: 'Visual indicators found but no aria-required attributes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2.4.2 Page Title ---
|
||||||
|
if (document.title && document.title.trim().length > 0) {
|
||||||
|
results.passes.push({ rule: '2.4.2', name: 'Page Title', detail: document.title });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '2.4.2', name: 'Page Title', severity: 'medium', detail: 'Page has no title' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.1.1 Language ---
|
||||||
|
const lang = document.documentElement.lang;
|
||||||
|
if (lang) {
|
||||||
|
results.passes.push({ rule: '3.1.1', name: 'Language', detail: lang });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '3.1.1', name: 'Language', severity: 'medium', detail: 'No lang attribute on <html>' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Images without alt ---
|
||||||
|
const imgsNoAlt = Array.from(document.querySelectorAll('img:not([alt])'));
|
||||||
|
if (imgsNoAlt.length === 0) {
|
||||||
|
results.passes.push({ rule: '1.1.1', name: 'Image Alt Text', detail: 'All images have alt attributes' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '1.1.1', name: 'Image Alt Text', severity: 'high', detail: `${imgsNoAlt.length} images without alt attributes` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Buttons without accessible names ---
|
||||||
|
const emptyButtons = Array.from(document.querySelectorAll('button')).filter(b => {
|
||||||
|
return !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby') && !b.getAttribute('title');
|
||||||
|
});
|
||||||
|
if (emptyButtons.length === 0) {
|
||||||
|
results.passes.push({ rule: '4.1.2', name: 'Button Names', detail: 'All buttons have accessible names' });
|
||||||
|
} else {
|
||||||
|
results.violations.push({ rule: '4.1.2', name: 'Button Names', severity: 'medium', detail: `${emptyButtons.length} buttons without accessible names` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
results.summary = {
|
||||||
|
violations: results.violations.length,
|
||||||
|
passes: results.passes.length,
|
||||||
|
warnings: results.warnings.length
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
return results;
|
||||||
|
})();
|
||||||
113
scripts/adr_manager.py
Normal file
113
scripts/adr_manager.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
[ARCH] ADR Manager
|
||||||
|
Part of the Gemini Sovereign Governance System.
|
||||||
|
|
||||||
|
Helps create and manage Architecture Decision Records (ADRs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import datetime
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
ADR_DIR = "docs/adr"
|
||||||
|
TEMPLATE_FILE = "docs/adr/ADR_TEMPLATE.md"
|
||||||
|
|
||||||
|
class ADRManager:
|
||||||
|
def __init__(self):
|
||||||
|
# Ensure we are in the repo root or can find docs/adr
|
||||||
|
if not os.path.exists(ADR_DIR):
|
||||||
|
# Try to find it relative to the script
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
repo_root = os.path.dirname(script_dir)
|
||||||
|
self.adr_dir = os.path.join(repo_root, ADR_DIR)
|
||||||
|
self.template_file = os.path.join(repo_root, TEMPLATE_FILE)
|
||||||
|
else:
|
||||||
|
self.adr_dir = ADR_DIR
|
||||||
|
self.template_file = TEMPLATE_FILE
|
||||||
|
|
||||||
|
if not os.path.exists(self.adr_dir):
|
||||||
|
os.makedirs(self.adr_dir)
|
||||||
|
|
||||||
|
def get_next_number(self):
|
||||||
|
files = [f for f in os.listdir(self.adr_dir) if f.endswith(".md") and f[0].isdigit()]
|
||||||
|
if not files:
|
||||||
|
return 1
|
||||||
|
numbers = [int(f.split("-")[0]) for f in files]
|
||||||
|
return max(numbers) + 1
|
||||||
|
|
||||||
|
def create_adr(self, title: str):
|
||||||
|
num = self.get_next_number()
|
||||||
|
slug = title.lower().replace(" ", "-").replace("/", "-")
|
||||||
|
filename = f"{num:04d}-{slug}.md"
|
||||||
|
filepath = os.path.join(self.adr_dir, filename)
|
||||||
|
|
||||||
|
date = datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
template = ""
|
||||||
|
if os.path.exists(self.template_file):
|
||||||
|
with open(self.template_file, "r") as f:
|
||||||
|
template = f.read()
|
||||||
|
else:
|
||||||
|
template = """# {num}. {title}
|
||||||
|
|
||||||
|
Date: {date}
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
What is the problem we are solving?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
What is the decision we made?
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
What are the positive and negative consequences?
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = template.replace("{num}", f"{num:04d}")
|
||||||
|
content = content.replace("{title}", title)
|
||||||
|
content = content.replace("{date}", date)
|
||||||
|
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print(f"[SUCCESS] Created ADR: {filepath}")
|
||||||
|
|
||||||
|
def list_adrs(self):
|
||||||
|
files = sorted([f for f in os.listdir(self.adr_dir) if f.endswith(".md") and f[0].isdigit()])
|
||||||
|
print(f"{'NUM':<6} {'TITLE'}")
|
||||||
|
print("-" * 40)
|
||||||
|
for f in files:
|
||||||
|
num = f.split("-")[0]
|
||||||
|
title = f.split("-", 1)[1].replace(".md", "").replace("-", " ").title()
|
||||||
|
print(f"{num:<6} {title}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Gemini ADR Manager")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
create_parser = subparsers.add_parser("new", help="Create a new ADR")
|
||||||
|
create_parser.add_argument("title", help="Title of the ADR")
|
||||||
|
|
||||||
|
subparsers.add_parser("list", help="List all ADRs")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manager = ADRManager()
|
||||||
|
|
||||||
|
if args.command == "new":
|
||||||
|
manager.create_adr(args.title)
|
||||||
|
elif args.command == "list":
|
||||||
|
manager.list_adrs()
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
65
scripts/agent_dispatch.py
Normal file
65
scripts/agent_dispatch.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
[OPS] Agent Dispatch Framework
|
||||||
|
Part of the Gemini Sovereign Infrastructure Suite.
|
||||||
|
|
||||||
|
Replaces ad-hoc dispatch scripts with a unified framework for tasking agents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
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 = {
|
||||||
|
"allegro": "167.99.126.228",
|
||||||
|
"bezalel": "159.203.146.185"
|
||||||
|
}
|
||||||
|
|
||||||
|
class Dispatcher:
|
||||||
|
def __init__(self, executor=None):
|
||||||
|
self.executor = executor or VerifiedSSHExecutor()
|
||||||
|
|
||||||
|
def log(self, message: str):
|
||||||
|
print(f"[*] {message}")
|
||||||
|
|
||||||
|
def dispatch(self, host: str, agent_name: str, task: str):
|
||||||
|
self.log(f"Dispatching task to {agent_name} on {host}...")
|
||||||
|
|
||||||
|
ip = FLEET[host]
|
||||||
|
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
self.log(f"[FAILURE] {agent_name} failed task.")
|
||||||
|
print(res.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"[ERROR] Dispatch failed: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Gemini Agent Dispatcher")
|
||||||
|
parser.add_argument("host", choices=list(FLEET.keys()), help="Host to dispatch to")
|
||||||
|
parser.add_argument("agent", help="Agent name")
|
||||||
|
parser.add_argument("task", help="Task description")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
dispatcher = Dispatcher()
|
||||||
|
dispatcher.dispatch(args.host, args.agent, args.task)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
126
scripts/agent_guardrails.py
Normal file
126
scripts/agent_guardrails.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agent_guardrails.py — Sovereign Agent Guardrails for the Timmy Foundation.
|
||||||
|
|
||||||
|
Scans agent logs and conversation trajectories for:
|
||||||
|
1. System Prompt Leakage — detecting if the agent revealed its internal instructions.
|
||||||
|
2. Jailbreak Compliance — detecting if the agent responded to "GODMODE", "DAN", etc.
|
||||||
|
3. PII Leakage — detecting if secrets or sensitive data were output.
|
||||||
|
|
||||||
|
Part of the Sovereign Infrastructure Suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Tuple
|
||||||
|
|
||||||
|
# Patterns adapted from hermes-agent/agent/input_sanitizer.py
|
||||||
|
EXTRACTION_PATTERNS = [
|
||||||
|
r"repeat\s+the\s+words\s+above",
|
||||||
|
r"repeat\s+your\s+(?:system\s+|initial\s+)?instructions",
|
||||||
|
r"what\s+is\s+your\s+(?:system\s+|initial\s+)?prompt",
|
||||||
|
r"show\s+me\s+your\s+(?:system\s+|initial\s+)?prompt",
|
||||||
|
r"print\s+your\s+(?:instructions|prompt|system\s+prompt)",
|
||||||
|
r"output\s+your\s+(?:initial|system)\s+prompt",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Patterns that indicate the agent MIGHT be leaking its prompt
|
||||||
|
LEAKAGE_INDICATORS = [
|
||||||
|
r"you\s+are\s+a\s+helpful\s+assistant",
|
||||||
|
r"your\s+goal\s+is\s+to",
|
||||||
|
r"you\s+must\s+not",
|
||||||
|
r"here\s+are\s+your\s+instructions",
|
||||||
|
r"my\s+system\s+prompt\s+is",
|
||||||
|
r"i\s+was\s+told\s+to",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Patterns for secrets (adapted from redact.py)
|
||||||
|
SECRET_PATTERNS = [
|
||||||
|
r"sk-[A-Za-z0-9_-]{20,}",
|
||||||
|
r"ghp_[A-Za-z0-9]{20,}",
|
||||||
|
r"AIza[A-Za-z0-9_-]{30,}",
|
||||||
|
]
|
||||||
|
|
||||||
|
AGENT_LOG_PATHS = [
|
||||||
|
"/root/wizards/*/home/logs/*.log",
|
||||||
|
"/root/wizards/*/logs/*.log",
|
||||||
|
"/root/wizards/*/.hermes/logs/*.log",
|
||||||
|
]
|
||||||
|
|
||||||
|
class GuardrailAuditor:
|
||||||
|
def __init__(self):
|
||||||
|
self.extraction_re = [re.compile(p, re.IGNORECASE) for p in EXTRACTION_PATTERNS]
|
||||||
|
self.leakage_re = [re.compile(p, re.IGNORECASE) for p in LEAKAGE_INDICATORS]
|
||||||
|
self.secret_re = [re.compile(p, re.IGNORECASE) for p in SECRET_PATTERNS]
|
||||||
|
|
||||||
|
def find_logs(self) -> List[Path]:
|
||||||
|
files = []
|
||||||
|
for pattern in AGENT_LOG_PATHS:
|
||||||
|
for p in glob.glob(pattern):
|
||||||
|
files.append(Path(p))
|
||||||
|
return files
|
||||||
|
|
||||||
|
def audit_file(self, path: Path) -> List[Dict]:
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(path, "r", errors="ignore") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
# Check for extraction attempts (User side)
|
||||||
|
for p in self.extraction_re:
|
||||||
|
if p.search(line):
|
||||||
|
findings.append({
|
||||||
|
"type": "EXTRACTION_ATTEMPT",
|
||||||
|
"line": i + 1,
|
||||||
|
"content": line.strip()[:100],
|
||||||
|
"severity": "MEDIUM"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for potential leakage (Assistant side)
|
||||||
|
for p in self.leakage_re:
|
||||||
|
if p.search(line):
|
||||||
|
findings.append({
|
||||||
|
"type": "POTENTIAL_LEAKAGE",
|
||||||
|
"line": i + 1,
|
||||||
|
"content": line.strip()[:100],
|
||||||
|
"severity": "HIGH"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for secrets
|
||||||
|
for p in self.secret_re:
|
||||||
|
if p.search(line):
|
||||||
|
findings.append({
|
||||||
|
"type": "SECRET_EXPOSURE",
|
||||||
|
"line": i + 1,
|
||||||
|
"content": "[REDACTED]",
|
||||||
|
"severity": "CRITICAL"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading {path}: {e}")
|
||||||
|
return findings
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
print("--- Sovereign Agent Guardrail Audit ---")
|
||||||
|
logs = self.find_logs()
|
||||||
|
print(f"Scanning {len(logs)} log files...")
|
||||||
|
|
||||||
|
total_findings = 0
|
||||||
|
for log in logs:
|
||||||
|
findings = self.audit_file(log)
|
||||||
|
if findings:
|
||||||
|
print(f"\nFindings in {log}:")
|
||||||
|
for f in findings:
|
||||||
|
print(f" [{f['severity']}] {f['type']} at line {f['line']}: {f['content']}")
|
||||||
|
total_findings += 1
|
||||||
|
|
||||||
|
print(f"\nAudit complete. Total findings: {total_findings}")
|
||||||
|
if total_findings > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
auditor = GuardrailAuditor()
|
||||||
|
auditor.run()
|
||||||
@@ -9,7 +9,7 @@ import re
|
|||||||
SOVEREIGN_RULES = [
|
SOVEREIGN_RULES = [
|
||||||
(r"https?://(api\.openai\.com|api\.anthropic\.com)", "CRITICAL: External cloud API detected. Use local custom_provider instead."),
|
(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"provider: (openai|anthropic)", "WARNING: Direct cloud provider used. Ensure fallback_model is configured."),
|
||||||
(r"api_key: ['"][^'"\s]{10,}['"]", "SECURITY: Hardcoded API key detected. Use environment variables.")
|
(r"api_key:\s*['\"][A-Za-z0-9_\-]{16,}['\"]", "SECURITY: Hardcoded API key detected. Use environment variables.")
|
||||||
]
|
]
|
||||||
|
|
||||||
def lint_file(path):
|
def lint_file(path):
|
||||||
|
|||||||
237
scripts/architecture_linter_v2.py
Normal file
237
scripts/architecture_linter_v2.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
[ARCH] Architecture Linter v2
|
||||||
|
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
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# --- CONFIGURATION ---
|
||||||
|
|
||||||
|
SOVEREIGN_KEYWORDS = ["mempalace", "sovereign_store", "tirith", "bezalel", "nexus"]
|
||||||
|
|
||||||
|
# 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.result = LinterResult(str(self.repo_path), self.repo_name)
|
||||||
|
|
||||||
|
# --- 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):
|
||||||
|
"""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):
|
||||||
|
"""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):
|
||||||
|
"""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):
|
||||||
|
"""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.result.errors.append(
|
||||||
|
'SOUL.md missing from canonical location (timmy-config root).'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if soul_path.exists():
|
||||||
|
self.result.errors.append(
|
||||||
|
'SOUL.md found in non-canonical repo. Must live only in timmy-config.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_readme(self):
|
||||||
|
"""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.read_text(errors='ignore')
|
||||||
|
if len(content.strip()) < 50:
|
||||||
|
self.result.warnings.append(
|
||||||
|
'README.md is very short (<50 chars). Provide current truth about the repo.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
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()
|
||||||
|
|
||||||
|
path = args.repo_flag if args.repo_flag else args.repo_path
|
||||||
|
|
||||||
|
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()
|
||||||
12
scripts/captcha_bypass_handler.py
Normal file
12
scripts/captcha_bypass_handler.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user