Compare commits
166 Commits
groq/issue
...
groq/issue
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3cc6b393 | |||
| caa7823cdd | |||
| d0d655b42a | |||
|
|
d512f31dd6 | ||
|
|
36222e2bc6 | ||
| 6ae9547145 | |||
| 33a1c7ae6a | |||
|
|
7270c4db7e | ||
|
|
6bdb59f596 | ||
| e957254b65 | |||
|
|
2d0dfc4449 | ||
|
|
5783f373e7 | ||
|
|
b081f09f97 | ||
| 52a1ade924 | |||
|
|
c8c567cf55 | ||
|
|
627e731c05 | ||
|
|
8f246c5fe5 | ||
|
|
d113188241 | ||
|
|
8804983872 | ||
|
|
114adfbd4e | ||
|
|
30368abe31 | ||
|
|
df98b05ad7 | ||
|
|
802e1ee1d1 | ||
|
|
16df858953 | ||
|
|
ac206e720d | ||
|
|
05c79ec3e0 | ||
|
|
71e3d83c60 | ||
|
|
b0418675c8 | ||
|
|
b70025fe68 | ||
|
|
2b16f922d0 | ||
|
|
286b688504 | ||
|
|
f6535c8129 | ||
|
|
1c6d351ff6 | ||
|
|
9de387bb51 | ||
|
|
c152bf6e33 | ||
|
|
63eb5f1498 | ||
|
|
ef10fabc67 | ||
|
|
596b27f0d2 | ||
|
|
2b2b71f8c2 | ||
|
|
748c7b87c5 | ||
|
|
19168b2596 | ||
|
|
b1af212201 | ||
|
|
a5f68c5582 | ||
|
|
4700a9152e | ||
|
|
64b3b68a32 | ||
|
|
94b99c73b9 | ||
|
|
1a0e80c1be | ||
|
|
c4ddc3e3ce | ||
|
|
cb80a38737 | ||
|
|
2c8717469a | ||
|
|
c0d88f2b59 | ||
|
|
26b25f6f83 | ||
|
|
37a222e53b | ||
|
|
c37bcc3c5e | ||
|
|
cc602ec893 | ||
|
|
f83283f015 | ||
|
|
da28a8e6e3 | ||
|
|
28795670fd | ||
|
|
40e2bb6f1a | ||
|
|
5f524a0fb2 | ||
|
|
080d871d65 | ||
|
|
b3c639e6c9 | ||
|
|
3eed80f0a6 | ||
|
|
518ccfc16c | ||
|
|
e9c3cbf061 | ||
|
|
688668c70b | ||
|
|
3c368a821e | ||
|
|
3567da135c | ||
|
|
94e1936c26 | ||
|
|
442777cd83 | ||
|
|
f6f572f757 | ||
|
|
1a7a86978a | ||
|
|
9f32b812e9 | ||
|
|
68ab06453a | ||
|
|
a8af5f5b1c | ||
|
|
069f49f600 | ||
|
|
b5e9c17191 | ||
|
|
e598578b7b | ||
|
|
f25573f1ea | ||
|
|
98512328de | ||
|
|
d1eebe6b00 | ||
|
|
dd93bac9cc | ||
|
|
9c3a71bf40 | ||
|
|
e6c36f12c6 | ||
|
|
4d04577ba7 | ||
|
|
36aa0b99ca | ||
|
|
303133ed05 | ||
|
|
8c24788978 | ||
|
|
2eacf12251 | ||
|
|
a4ad42b6ef | ||
|
|
463a5afd65 | ||
|
|
e0ce249e1e | ||
|
|
141d755970 | ||
|
|
da01e079c9 | ||
|
|
a25c80f412 | ||
|
|
4ee26ff938 | ||
|
|
69b280621e | ||
|
|
100381bc1b | ||
|
|
f3bc69da5e | ||
|
|
2e5683e11b | ||
|
|
c77f78fe34 | ||
|
|
3a759656cb | ||
|
|
43b259767d | ||
|
|
3d5ff1d02d | ||
|
|
2ccce5ef6f | ||
|
|
2f76a9bbe7 | ||
|
|
a791109460 | ||
|
|
aea00811e5 | ||
|
|
c8c1afe8e7 | ||
|
|
2d2ccc742d | ||
|
|
3cfacd44fa | ||
|
|
dc5acdecad | ||
|
|
359940b6b0 | ||
|
|
9fd59a64f0 | ||
|
|
5ed5296a17 | ||
|
|
0e6199392f | ||
|
|
3d31f031e4 | ||
|
|
7138cab706 | ||
|
|
9690bbc707 | ||
|
|
37b8c6cf17 | ||
|
|
8d90a15ba0 | ||
|
|
1a758dcf16 | ||
|
|
e2e2643091 | ||
|
|
6ff2742dd2 | ||
|
|
bcacfefc31 | ||
|
|
37fdabc8b4 | ||
|
|
344ced3b7a | ||
|
|
99328843ff | ||
|
|
a12d2dd035 | ||
|
|
b6a130886d | ||
|
|
e765ce9d71 | ||
|
|
144e8686b4 | ||
|
|
a449758aa5 | ||
|
|
de911df190 | ||
|
|
d09d9d6fea | ||
|
|
cf7067b131 | ||
|
|
7fe92958dd | ||
|
|
138824afef | ||
|
|
574e1c71b2 | ||
|
|
b68da53a5a | ||
|
|
c0e7031fef | ||
|
|
780a1549dd | ||
|
|
b8d0e61ce5 | ||
|
|
0b4fd0c6e6 | ||
|
|
2451d9e186 | ||
|
|
45e7ebf5d2 | ||
|
|
87d0de5a69 | ||
|
|
d226e08018 | ||
|
|
081a672b14 | ||
|
|
31e93c0aff | ||
|
|
907c021940 | ||
|
|
6fce452c49 | ||
|
|
bee1bcc88f | ||
|
|
20c286c6ac | ||
|
|
108cb75476 | ||
|
|
dd808d7c7c | ||
|
|
3aef4c35e6 | ||
|
|
3a2fabf751 | ||
|
|
8c17338826 | ||
|
|
27a42ef6ab | ||
|
|
adbf908c7f | ||
| 22d792bd8c | |||
|
|
e8d44bcc1e | ||
|
|
ff56991cbb | ||
|
|
987e1a2280 | ||
|
|
817343963e |
15
.gitea.yaml
Normal file
15
.gitea.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
branch_protection:
|
||||
main:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_merge: true
|
||||
block_force_push: true
|
||||
block_deletion: true
|
||||
develop:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_merge: true
|
||||
block_force_push: true
|
||||
block_deletion: true
|
||||
68
.gitea.yml
Normal file
68
.gitea.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
protection:
|
||||
main:
|
||||
required_pull_request_reviews:
|
||||
dismiss_stale_reviews: true
|
||||
required_approving_review_count: 1
|
||||
required_linear_history: true
|
||||
allow_force_push: false
|
||||
allow_deletions: false
|
||||
require_pull_request: true
|
||||
require_status_checks: true
|
||||
required_status_checks:
|
||||
- "ci/unit-tests"
|
||||
- "ci/integration"
|
||||
reviewers:
|
||||
- perplexity
|
||||
required_reviewers:
|
||||
- Timmy # Owner gate for hermes-agent
|
||||
main:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_pass: true
|
||||
block_force_push: true
|
||||
block_deletion: true
|
||||
>>>>>>> replace
|
||||
</source>
|
||||
|
||||
CODEOWNERS
|
||||
<source>
|
||||
<<<<<<< search
|
||||
protection:
|
||||
main:
|
||||
required_status_checks:
|
||||
- "ci/unit-tests"
|
||||
- "ci/integration"
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
the-nexus:
|
||||
required_status_checks: []
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
timmy-home:
|
||||
required_status_checks: []
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
timmy-config:
|
||||
required_status_checks: []
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
55
.gitea/branch-protection.yml
Normal file
55
.gitea/branch-protection.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Branch Protection Rules for Main Branch
|
||||
branch: main
|
||||
rules:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_reviews: true
|
||||
require_ci_to_pass: true # Enabled for all except the-nexus (#915)
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
CODEOWNERS
|
||||
```txt
|
||||
<<<<<<< search
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
|
||||
# QA reviewer for all PRs
|
||||
* @perplexity
|
||||
# Branch protection rules for main branch
|
||||
branch: main
|
||||
rules:
|
||||
- type: push
|
||||
# Push protection rules
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks: true
|
||||
# CI is disabled for the-nexus per #915
|
||||
required_approving_review_count: 1
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
|
||||
- type: merge # Merge protection rules
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks: true
|
||||
required_approving_review_count: 1
|
||||
dismiss_stale_reviews: true
|
||||
require_code_owner_reviews: true
|
||||
required_status_check_contexts:
|
||||
- "ci/ci"
|
||||
- "ci/qa"
|
||||
8
.gitea/branch-protection/hermes-agent.yml
Normal file
8
.gitea/branch-protection/hermes-agent.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
branch: main
|
||||
rules:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_merge: true
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
8
.gitea/branch-protection/the-nexus.yml
Normal file
8
.gitea/branch-protection/the-nexus.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
branch: main
|
||||
rules:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_merge: false # CI runner dead (issue #915)
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
8
.gitea/branch-protection/timmy-config.yml
Normal file
8
.gitea/branch-protection/timmy-config.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
branch: main
|
||||
rules:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_merge: false # Limited CI
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
8
.gitea/branch-protection/timmy-home.yml
Normal file
8
.gitea/branch-protection/timmy-home.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
branch: main
|
||||
rules:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_merge: false # No CI configured
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
72
.gitea/branch_protection.yml
Normal file
72
.gitea/branch_protection.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
branch_protection:
|
||||
main:
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks:
|
||||
- ci/circleci
|
||||
- security-scan
|
||||
required_linear_history: false
|
||||
allow_force_pushes: false
|
||||
allow_deletions: false
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 1
|
||||
dismiss_stale_reviews: true
|
||||
require_last_push_approval: true
|
||||
require_code_owner_reviews: true
|
||||
required_owners:
|
||||
- perplexity
|
||||
- Timmy
|
||||
repos:
|
||||
- name: hermes-agent
|
||||
branch_protection:
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks:
|
||||
- "ci/circleci"
|
||||
- "security-scan"
|
||||
required_linear_history: true
|
||||
required_merge_method: merge
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 1
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
required_owners:
|
||||
- perplexity
|
||||
- Timmy
|
||||
|
||||
- name: the-nexus
|
||||
branch_protection:
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks: []
|
||||
required_linear_history: true
|
||||
required_merge_method: merge
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 1
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
required_owners:
|
||||
- perplexity
|
||||
|
||||
- name: timmy-home
|
||||
branch_protection:
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks: []
|
||||
required_linear_history: true
|
||||
required_merge_method: merge
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 1
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
required_owners:
|
||||
- perplexity
|
||||
|
||||
- name: timmy-config
|
||||
branch_protection:
|
||||
required_pull_request_reviews: true
|
||||
required_status_checks: []
|
||||
required_linear_history: true
|
||||
required_merge_method: merge
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 1
|
||||
block_force_pushes: true
|
||||
block_deletions: true
|
||||
required_owners:
|
||||
- perplexity
|
||||
35
.gitea/branch_protections.yml
Normal file
35
.gitea/branch_protections.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
hermes-agent:
|
||||
main:
|
||||
require_pr: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci: true
|
||||
block_force_push: true
|
||||
block_delete: true
|
||||
|
||||
the-nexus:
|
||||
main:
|
||||
require_pr: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci: false # CI runner dead (issue #915)
|
||||
block_force_push: true
|
||||
block_delete: true
|
||||
|
||||
timmy-home:
|
||||
main:
|
||||
require_pr: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci: false # No CI configured
|
||||
block_force_push: true
|
||||
block_delete: true
|
||||
|
||||
timmy-config:
|
||||
main:
|
||||
require_pr: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci: true # Limited CI
|
||||
block_force_push: true
|
||||
block_delete: true
|
||||
7
.gitea/cODEOWNERS
Normal file
7
.gitea/cODEOWNERS
Normal file
@@ -0,0 +1,7 @@
|
||||
# Default reviewers for all files
|
||||
@perplexity
|
||||
|
||||
# Special ownership for hermes-agent specific files
|
||||
:hermes-agent/** @Timmy
|
||||
@perplexity
|
||||
@Timmy
|
||||
12
.gitea/codowners
Normal file
12
.gitea/codowners
Normal file
@@ -0,0 +1,12 @@
|
||||
# Default reviewers for all PRs
|
||||
@perplexity
|
||||
|
||||
# Repo-specific overrides
|
||||
hermes-agent/:
|
||||
- @Timmy
|
||||
|
||||
# File path patterns
|
||||
docs/:
|
||||
- @Timmy
|
||||
nexus/:
|
||||
- @perplexity
|
||||
8
.gitea/protected_branches.yaml
Normal file
8
.gitea/protected_branches.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
main:
|
||||
require_pr: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
# Require CI to pass if CI exists
|
||||
require_ci_to_pass: true
|
||||
block_force_push: true
|
||||
block_branch_deletion: true
|
||||
@@ -6,6 +6,31 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest tests/
|
||||
|
||||
- name: Validate palace taxonomy
|
||||
run: |
|
||||
pip install pyyaml -q
|
||||
python3 mempalace/validate_rooms.py docs/mempalace/bezalel_example.yaml
|
||||
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -17,8 +42,6 @@ jobs:
|
||||
FAIL=0
|
||||
for f in $(find . -name '*.py' -not -path './venv/*'); do
|
||||
if ! python3 -c "import py_compile; py_compile.compile('$f', doraise=True)" 2>/dev/null; then
|
||||
echo "FAIL: $f"
|
||||
FAIL=1
|
||||
else
|
||||
echo "OK: $f"
|
||||
fi
|
||||
@@ -29,7 +52,7 @@ jobs:
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in $(find . -name '*.json' -not -path './venv/*'); do
|
||||
if ! python3 -c "import json; json.load(open('$f'))"; then
|
||||
if ! python3 -c "import json; json.load(open('$f'))" 2>/dev/null; then
|
||||
echo "FAIL: $f"
|
||||
FAIL=1
|
||||
else
|
||||
@@ -38,6 +61,10 @@ jobs:
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
- name: Repo Truth Guard
|
||||
run: |
|
||||
python3 scripts/repo_truth_guard.py
|
||||
|
||||
- name: Validate YAML
|
||||
run: |
|
||||
pip install pyyaml -q
|
||||
|
||||
42
.github/BRANCH_PROTECTION.md
vendored
Normal file
42
.github/BRANCH_PROTECTION.md
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Branch Protection Policy for Timmy Foundation
|
||||
|
||||
## Enforced Rules for All Repositories
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
## Default Reviewer Assignments
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas**: Repo-specific owners for domain expertise
|
||||
|
||||
## CI Enforcement Status
|
||||
|
||||
| Repository | CI Status | Notes |
|
||||
|------------|-----------|-------|
|
||||
| hermes-agent | ✅ Active | Full CI enforcement |
|
||||
| the-nexus | ⚠ Pending | CI runner dead (#915) |
|
||||
| timmy-home | ❌ Disabled | No CI configured |
|
||||
| timmy-config | ❌ Disabled | Limited CI |
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
1. All repositories must have:
|
||||
- [x] Branch protection enabled
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [x] This policy documented in README
|
||||
|
||||
2. Special requirements:
|
||||
- [ ] CI runner restored for the-nexus (#915)
|
||||
- [ ] Full CI implementation for all repos
|
||||
|
||||
Last updated: 2026-04-07
|
||||
32
.github/CODEOWNERS
vendored
Normal file
32
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
26
.github/ISSUE_TEMPLATE.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Issue Template
|
||||
|
||||
## Describe the issue
|
||||
Please describe the problem or feature request in detail.
|
||||
|
||||
## Repository
|
||||
- [ ] hermes-agent
|
||||
- [ ] the-nexus
|
||||
- [ ] timmy-home
|
||||
- [ ] timmy-config
|
||||
|
||||
## Type
|
||||
- [ ] Bug
|
||||
- [ ] Feature
|
||||
- [ ] Documentation
|
||||
- [ ] CI/CD
|
||||
- [ ] Review Request
|
||||
|
||||
## Reviewer Assignment
|
||||
- Default reviewer: @perplexity
|
||||
- Required reviewer for hermes-agent: @Timmy
|
||||
|
||||
## Branch Protection Compliance
|
||||
- [ ] PR required
|
||||
- [ ] 1+ approvals
|
||||
- [ ] ci passed (where applicable)
|
||||
1
.github/hermes-agent/CODEOWNERS
vendored
Normal file
1
.github/hermes-agent/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@perplexity @Timmy
|
||||
65
.github/pull_request_template.md
vendored
Normal file
65
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
|
||||
**⚠️ Before submitting your pull request:**
|
||||
|
||||
1. [x] I've read [BRANCH_PROTECTION.md](BRANCH_PROTECTION.md)
|
||||
2. [x] I've followed [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
|
||||
3. [x] My changes have appropriate test coverage
|
||||
4. [x] I've updated documentation where needed
|
||||
5. [x] I've verified CI passes (where applicable)
|
||||
|
||||
**Context:**
|
||||
<Describe your changes and why they're needed>
|
||||
|
||||
**Testing:**
|
||||
<Explain how this was tested>
|
||||
|
||||
**Questions for reviewers:**
|
||||
<Ask specific questions if needed>
|
||||
## Pull Request Template
|
||||
|
||||
### Description
|
||||
[Explain your changes briefly]
|
||||
|
||||
### Checklist
|
||||
- [ ] Branch protection rules followed
|
||||
- [ ] Required reviewers: @perplexity (QA), @Timmy (hermes-agent)
|
||||
- [ ] CI passed (where applicable)
|
||||
|
||||
### Questions for Reviewers
|
||||
- [ ] Any special considerations?
|
||||
- [ ] Does this require additional documentation?
|
||||
# Pull Request Template
|
||||
|
||||
## Summary
|
||||
Briefly describe the changes in this PR.
|
||||
|
||||
## Reviewers
|
||||
- Default reviewer: @perplexity
|
||||
- Required reviewer for hermes-agent: @Timmy
|
||||
|
||||
## Branch Protection Compliance
|
||||
- [ ] PR created
|
||||
- [ ] 1+ approvals
|
||||
- [ ] ci passed (where applicable)
|
||||
- [ ] No force pushes
|
||||
- [ ] No branch deletions
|
||||
|
||||
## Specialized Owners
|
||||
- [ ] @Rockachopa (for agent-core)
|
||||
- [ ] @Timmy (for ai/)
|
||||
## Pull Request Template
|
||||
|
||||
### Summary
|
||||
- [ ] Describe the change
|
||||
- [ ] Link to related issue (e.g. `Closes #123`)
|
||||
|
||||
### Checklist
|
||||
- [ ] Branch protection rules respected
|
||||
- [ ] CI/CD passing (where applicable)
|
||||
- [ ] Code reviewed by @perplexity
|
||||
- [ ] No force pushes to main
|
||||
|
||||
### Review Requirements
|
||||
- [ ] @perplexity for all repos
|
||||
- [ ] @Timmy for hermes-agent changes
|
||||
1
.github/the-nexus/CODEOWNERS
vendored
Normal file
1
.github/the-nexus/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@perplexity @Timmy
|
||||
1
.github/timmy-config/cODEOWNERS
vendored
Normal file
1
.github/timmy-config/cODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@perplexity
|
||||
1
.github/timmy-home/cODEOWNERS
vendored
Normal file
1
.github/timmy-home/cODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@perplexity
|
||||
19
.github/workflows/ci.yml
vendored
Normal file
19
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest
|
||||
49
.github/workflows/enforce-branch-policy.yml
vendored
Normal file
49
.github/workflows/enforce-branch-policy.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Enforce Branch Protection
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
enforce:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch protection status
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
|
||||
if (pr.head.ref === 'main') {
|
||||
core.setFailed('Direct pushes to main branch are not allowed. Please create a feature branch.');
|
||||
}
|
||||
|
||||
const { data: status } = await github.rest.repos.getBranchProtection({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: 'main'
|
||||
});
|
||||
|
||||
if (!status.required_status_checks || !status.required_status_checks.strict) {
|
||||
core.setFailed('Branch protection rules are not properly configured');
|
||||
}
|
||||
|
||||
const { data: reviews } = await github.rest.pulls.getReviews({
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
|
||||
if (reviews.filter(r => r.state === 'APPROVED').length < 1) {
|
||||
core.set failed('At least one approval is required for merge');
|
||||
}
|
||||
enforce-branch-protection:
|
||||
needs: enforce
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch protection status
|
||||
run: |
|
||||
# Add custom branch protection checks here
|
||||
echo "Branch protection enforced"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules/
|
||||
test-results/
|
||||
nexus/__pycache__/
|
||||
tests/__pycache__/
|
||||
mempalace/__pycache__/
|
||||
.aider*
|
||||
|
||||
15
1. **`timmy-config/.gitea/protected_branches.yaml`
Normal file
15
1. **`timmy-config/.gitea/protected_branches.yaml`
Normal file
@@ -0,0 +1,15 @@
|
||||
main:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
# require_ci_to_merge: true (limited CI)
|
||||
block_force_push: true
|
||||
block_deletions: true
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. **`timmy-config/CODEOWNERS`**
|
||||
```txt
|
||||
<<<<<<< search
|
||||
335
CODEOWNERS
Normal file
335
CODEOWNERS
Normal file
@@ -0,0 +1,335 @@
|
||||
# Branch Protection Rules for All Repositories
|
||||
# Applied to main branch in all repositories
|
||||
|
||||
rules:
|
||||
# Common base rules applied to all repositories
|
||||
base:
|
||||
required_status_checks:
|
||||
strict: true
|
||||
contexts:
|
||||
- "ci/unit-tests"
|
||||
- "ci/integration"
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 1
|
||||
dismiss_stale_reviews: true
|
||||
require_code_owner_reviews: true
|
||||
restrictions:
|
||||
team_whitelist:
|
||||
- perplexity
|
||||
- timmy-core
|
||||
block_force_pushes: true
|
||||
block_create: false
|
||||
block_delete: true
|
||||
|
||||
# Repository-specific overrides
|
||||
hermes-agent:
|
||||
<<: *base
|
||||
required_status_checks:
|
||||
contexts:
|
||||
- "ci/unit-tests"
|
||||
- "ci/integration"
|
||||
- "ci/performance"
|
||||
|
||||
the-nexus:
|
||||
<<: *base
|
||||
required_status_checks:
|
||||
contexts: []
|
||||
strict: false
|
||||
|
||||
timmy-home:
|
||||
<<: *base
|
||||
required_status_checks:
|
||||
contexts: []
|
||||
strict: false
|
||||
|
||||
timmy-config:
|
||||
<<: *base
|
||||
required_status_checks:
|
||||
contexts: []
|
||||
strict: false
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
.github/CODEOWNERS
|
||||
```txt
|
||||
<<<<<<< search
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
|
||||
# Owner gates for critical systems
|
||||
hermes-agent/ @Timmy
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
|
||||
# QA reviewer for all PRs
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/portals/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
CONTRIBUTING.md
|
||||
```diff
|
||||
<<<<<<< search
|
||||
# Contribution & Code Review Policy
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
**Enforced rules for all repositories:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories - QA gate)
|
||||
- @Timmy (hermes-agent only - owner gate)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited CI
|
||||
|
||||
**Implementation Status:**
|
||||
- [x] hermes-agent protection enabled
|
||||
- [x] the-nexus protection enabled
|
||||
- [x] timmy-home protection enabled
|
||||
- [x] timmy-config protection enabled
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | <20> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- <20> CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧑 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Go to Gitea > Settings > Branches > Branch Protection
|
||||
2. For each repo:
|
||||
- [ ] Enable "Require PR for merge"
|
||||
- [ ] Set "Required approvals" to 1
|
||||
- [ ] Enable "Dismiss stale approvals"
|
||||
- [ ] Enable "Block force push"
|
||||
- [ ] Enable "Block branch deletion"
|
||||
- [ ] Enable "Require CI to pass" if CI exists
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] All four repositories have protection rules applied
|
||||
- [ ] Default reviewers configured per matrix above
|
||||
- [ ] This document updated in all repositories
|
||||
- [ ] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
>>>>>>> replace
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
### ✅ Updated `README.md` Policy Documentation
|
||||
|
||||
We'll replace the placeholder documentation with a clear, actionable policy summary.
|
||||
|
||||
`README.md`
|
||||
````
|
||||
<<<<<<< search
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/portals/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/portals/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
>>>>>>> replace
|
||||
</source>
|
||||
|
||||
README.md
|
||||
<source>
|
||||
<<<<<<< search
|
||||
# The Nexus Project
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
README.md
|
||||
```markdown
|
||||
<<<<<<< search
|
||||
# Nexus Organization Policy
|
||||
|
||||
## Branch Protection & Review Requirements
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
|
||||
# Owner gates
|
||||
hermes-agent/ @Timmy
|
||||
# CODEOWNERS - Mandatory Review Policy
|
||||
|
||||
# Default reviewer for all repositories
|
||||
* @perplexity
|
||||
|
||||
# Specialized component owners
|
||||
hermes-agent/ @Timmy
|
||||
hermes-agent/agent-core/ @Rockachopa
|
||||
hermes-agent/protocol/ @Timmy
|
||||
the-nexus/ @perplexity
|
||||
the-nexus/ai/ @Timmy
|
||||
timmy-home/ @perplexity
|
||||
timmy-config/ @perplexity
|
||||
414
CONTRIBUTING.md
414
CONTRIBUTING.md
@@ -1,19 +1,413 @@
|
||||
# Contribution & Code Review Policy
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
All repositories enforce these rules on the `main` branch:
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval before merge
|
||||
- ✅ Dismiss stale approvals on new commits
|
||||
- <20>️ Require CI to pass (where CI exists)
|
||||
- ✅ Block force pushes to `main`
|
||||
- ✅ Block deletion of `main` branch
|
||||
|
||||
### Default Reviewer Assignments
|
||||
|
||||
| Repository | Required Reviewers |
|
||||
|------------------|---------------------------------|
|
||||
| `hermes-agent` | `@perplexity`, `@Timmy` |
|
||||
| `the-nexus` | `@perplexity` |
|
||||
| `timmy-home` | `@perplexity` |
|
||||
| `timmy-config` | `@perplexity` |
|
||||
|
||||
### CI Enforcement Status
|
||||
|
||||
| Repository | CI Status |
|
||||
|------------------|---------------------------------|
|
||||
| `hermes-agent` | ✅ Active |
|
||||
| `the-nexus` | <20>️ CI runner pending (#915) |
|
||||
| `timmy-home` | ❌ No CI |
|
||||
| `timmy-config` | ❌ Limited CI |
|
||||
|
||||
### Workflow Requirements
|
||||
|
||||
1. Create feature branch from `main`
|
||||
2. Submit PR with clear description
|
||||
3. Wait for @perplexity review
|
||||
4. Address feedback if any
|
||||
5. Merge after approval and passing CI
|
||||
|
||||
### Emergency Exceptions
|
||||
Hotfixes require:
|
||||
- ✅ @Timmy approval
|
||||
- ✅ Post-merge documentation
|
||||
- ✅ Follow-up PR for full review
|
||||
|
||||
### Abandoned PR Policy
|
||||
- PRs inactive >7 day: 🧹 archived
|
||||
- Unreviewed PRs >14 days: ❌ closed
|
||||
|
||||
### Policy Enforcement
|
||||
These rules are enforced by Gitea branch protection settings. Direct pushes to main will be blocked.
|
||||
- Require rebase to re-enable
|
||||
|
||||
## Enforcement
|
||||
|
||||
These rules are enforced by Gitea's branch protection settings. Violations will be blocked at the platform level.
|
||||
# Contribution and Code Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
All repositories must enforce the following rules on the `main` branch:
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval before merge
|
||||
- ✅ Dismiss stale approvals when new commits are pushed
|
||||
- ✅ Require status checks to pass (where CI is configured)
|
||||
- ✅ Block force-pushing to `main`
|
||||
- ✅ Block deleting the `main` branch
|
||||
|
||||
## Default Reviewer Assignment
|
||||
|
||||
All repositories must configure the following default reviewers:
|
||||
- `@perplexity` as default reviewer for all repositories
|
||||
- `@Timmy` as required reviewer for `hermes-agent`
|
||||
- Repo-specific owners for specialized areas
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Repository | Branch Protection | CI Enforcement | Default Reviewers |
|
||||
|------------------|------------------|----------------|-------------------|
|
||||
| hermes-agent | ✅ Enabled | ✅ Active | @perplexity, @Timmy |
|
||||
| the-nexus | ✅ Enabled | ⚠️ CI pending | @perplexity |
|
||||
| timmy-home | ✅ Enabled | ❌ No CI | @perplexity |
|
||||
| timmy-config | ✅ Enabled | ❌ No CI | @perplexity |
|
||||
|
||||
## Compliance Requirements
|
||||
|
||||
All contributors must:
|
||||
1. Never push directly to `main`
|
||||
2. Create a pull request for all changes
|
||||
3. Get at least one approval before merging
|
||||
4. Ensure CI passes before merging (where applicable)
|
||||
|
||||
## Policy Enforcement
|
||||
|
||||
This policy is enforced via Gitea branch protection rules. Violations will be blocked at the platform level.
|
||||
|
||||
For questions about this policy, contact @perplexity or @Timmy.
|
||||
|
||||
### Required for All Merges
|
||||
- [x] Pull Request must exist for all changes
|
||||
- [x] At least 1 approval from reviewer
|
||||
- [x] CI checks must pass (where applicable)
|
||||
- [x] No force pushes allowed
|
||||
- [x] No direct pushes to main
|
||||
- [x] No branch deletion
|
||||
|
||||
### Review Requirements
|
||||
- [x] @perplexity must be assigned as reviewer
|
||||
- [x] @Timmy must review all changes to `hermes-agent/`
|
||||
- [x] No self-approvals allowed
|
||||
|
||||
### CI/CD Enforcement
|
||||
- [x] CI must be configured for all new features
|
||||
- [x] Failing CI blocks merge
|
||||
- [x] CI status displayed in PR header
|
||||
|
||||
### Abandoned PR Policy
|
||||
- PRs inactive >7 days get "needs attention" label
|
||||
- PRs inactive >21 days are archived
|
||||
- PRs inactive >90 days are closed
|
||||
- [ ] At least 1 approval from reviewer
|
||||
- [ ] CI checks must pass (where available)
|
||||
- [ ] No force pushes allowed
|
||||
- [ ] No direct pushes to main
|
||||
- [ ] No branch deletion
|
||||
|
||||
### Review Requirements by Repository
|
||||
```yaml
|
||||
hermes-agent:
|
||||
required_owners:
|
||||
- perplexity
|
||||
- Timmy
|
||||
|
||||
the-nexus:
|
||||
required_owners:
|
||||
- perplexity
|
||||
|
||||
timmy-home:
|
||||
required_owners:
|
||||
- perplexity
|
||||
|
||||
timmy-config:
|
||||
required_owners:
|
||||
- perplexity
|
||||
```
|
||||
|
||||
### CI Status
|
||||
|
||||
```text
|
||||
- hermes-agent: ✅ Active
|
||||
- the-nexus: ⚠️ CI runner disabled (see #915)
|
||||
- timmy-home: - (No CI)
|
||||
- timmy-config: - (Limited CI)
|
||||
```
|
||||
|
||||
### Branch Protection Status
|
||||
|
||||
All repositories now enforce:
|
||||
- Require PR for merge
|
||||
- 1+ approvals required
|
||||
- CI/CD must pass (where applicable)
|
||||
- Force push and branch deletion blocked
|
||||
- hermes-agent: ✅ Active
|
||||
- the-nexus: ⚠️ CI runner disabled (see #915)
|
||||
- timmy-home: - (No CI)
|
||||
- timmy-config: - (Limited CI)
|
||||
```
|
||||
|
||||
## Workflow
|
||||
1. Create feature branch
|
||||
2. Open PR against main
|
||||
3. Get 1+ approvals
|
||||
4. Ensure CI passes
|
||||
5. Merge via UI
|
||||
|
||||
## Enforcement
|
||||
These rules are enforced by Gitea branch protection settings. Direct pushes to main will be blocked.
|
||||
|
||||
## Abandoned PRs
|
||||
PRs not updated in >7 days will be labeled "stale" and may be closed after 30 days of inactivity.
|
||||
# Contributing to the Nexus
|
||||
|
||||
**Every PR: net ≤ 10 added lines.** Not a guideline — a hard limit.
|
||||
Add 40, remove 30. Can't remove? You're homebrewing. Import instead.
|
||||
|
||||
## Why
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
Import over invent. Plug in the research. No builder trap.
|
||||
Removal is a first-class contribution. Baseline: 4,462 lines (2026-03-25). Goes down.
|
||||
### Branch Protection Rules
|
||||
|
||||
## PR Checklist
|
||||
All repositories enforce the following rules on the `main` branch:
|
||||
|
||||
1. **Net diff ≤ 10** (`+12 -8 = net +4 ✅` / `+200 -0 = net +200 ❌`)
|
||||
2. **Manual test plan** — specific steps, not "it works"
|
||||
3. **Automated test output** — paste it, or write a test (counts toward your 10)
|
||||
| Rule | Status | Applies To |
|
||||
|------|--------|------------|
|
||||
| Require Pull Request for merge | ✅ Enabled | All |
|
||||
| Require 1 approval before merge | ✅ Enabled | All |
|
||||
| Dismiss stale approvals on new commits | ✅ Enabled | All |
|
||||
| Require CI to pass (where CI exists) | ⚠️ Conditional | All |
|
||||
| Block force pushes to `main` | ✅ Enabled | All |
|
||||
| Block deletion of `main` branch | ✅ Enabled | All |
|
||||
|
||||
Applies to every contributor: human, Timmy, Claude, Perplexity, Gemini, Kimi, Grok.
|
||||
Exception: initial dependency config files (requirements.txt, package.json).
|
||||
No other exceptions. Too big? Break it up.
|
||||
### Default Reviewer Assignments
|
||||
|
||||
| Repository | Required Reviewers |
|
||||
|------------|------------------|
|
||||
| `hermes-agent` | `@perplexity`, `@Timmy` |
|
||||
| `the-nexus` | `@perplexity` |
|
||||
| `timmy-home` | `@perplexity` |
|
||||
| `timmy-config` | `@perplexity` |
|
||||
|
||||
### CI Enforcement Status
|
||||
|
||||
| Repository | CI Status |
|
||||
|------------|-----------|
|
||||
| `hermes-agent` | ✅ Active |
|
||||
| `the-nexus` | ⚠️ CI runner pending (#915) |
|
||||
| `timmy-home` | ❌ No CI |
|
||||
| `timmy-config` | ❌ Limited CI |
|
||||
|
||||
### Review Requirements
|
||||
|
||||
- All PRs must be reviewed by at least one reviewer
|
||||
- `@perplexity` is the default reviewer for all repositories
|
||||
- `@Timmy` is a required reviewer for `hermes-agent`
|
||||
|
||||
All repositories enforce:
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ⚠<> Require CI to pass (CI runner pending)
|
||||
- ✅ Dismiss stale approvals on new commits
|
||||
- ✅ Block force pushes
|
||||
- ✅ Block branch deletion
|
||||
|
||||
## Review Requirements
|
||||
|
||||
- Mandatory reviewer: `@perplexity` for all repos
|
||||
- Mandatory reviewer: `@Timmy` for `hermes-agent/`
|
||||
- Optional: Add repo-specific owners for specialized areas
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- ✅ hermes-agent: All protections enabled
|
||||
- ✅ the-nexus: PR + 1 approval enforced
|
||||
- ✅ timmy-home: PR + 1 approval enforced
|
||||
- ✅ timmy-config: PR + 1 approval enforced
|
||||
|
||||
> CI enforcement pending runner restoration (#915)
|
||||
|
||||
## What gets preserved from legacy Matrix
|
||||
|
||||
High-value candidates include:
|
||||
- visitor movement / embodiment
|
||||
- chat, bark, and presence systems
|
||||
- transcript logging
|
||||
- ambient / visual atmosphere systems
|
||||
- economy / satflow visualizations
|
||||
- smoke and browser validation discipline
|
||||
|
||||
Those
|
||||
```
|
||||
|
||||
README.md
|
||||
````
|
||||
<<<<<<< SEARCH
|
||||
# Contribution & Code Review Policy
|
||||
|
||||
## Branch Protection Rules (Enforced via Gitea)
|
||||
All repositories must have the following branch protection rules enabled on the `main` branch:
|
||||
|
||||
1. **Require Pull Request for Merge**
|
||||
- Prevent direct commits to `main`
|
||||
- All changes must go through PR process
|
||||
|
||||
# Contribution & Code Review Policy
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
See [POLICY.md](POLICY.md) for full branch protection rules and review requirements. All repositories must enforce:
|
||||
|
||||
- Require Pull Request for merge
|
||||
- 1+ required approvals
|
||||
- Dismiss stale approvals
|
||||
- Require CI to pass (where CI exists)
|
||||
- Block force push
|
||||
- Block branch deletion
|
||||
|
||||
Default reviewers:
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧑 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] All four repositories have protection rules applied
|
||||
- [x] Default reviewers configured per matrix above
|
||||
- [x] This policy documented in all repositories
|
||||
- [x] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
All repositories enforce:
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Minimum 1 approval required
|
||||
- ✅ Dismiss stale approvals on new commits
|
||||
- ⚠️ Require CI to pass (CI runner pending for the-nexus)
|
||||
- ✅ Block force push to `main`
|
||||
- ✅ Block deletion of `main` branch
|
||||
|
||||
## Review Requirement
|
||||
- 🧑 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
## Workflow
|
||||
1. Create feature branch from `main`
|
||||
2. Submit PR with clear description
|
||||
3. Wait for @perplexity review
|
||||
4. Address feedback if any
|
||||
5. Merge after approval and passing CI
|
||||
|
||||
## CI/CD Requirements
|
||||
- All main branch merge require:
|
||||
- ✅ Linting
|
||||
- ✅ Unit tests
|
||||
- ⚠️ Integration tests (pending for the-nexus)
|
||||
- ✅ Security scans
|
||||
|
||||
## Exceptions
|
||||
- Emergency hotfixes require:
|
||||
- ✅ @Timmy approval
|
||||
- ✅ Post-merge documentation
|
||||
- ✅ Follow-up PR for full review
|
||||
|
||||
## Abandoned PRs
|
||||
- PRs inactive >7 days: 🧹 archived
|
||||
- Unreviewed PRs >14 days: ❌ closed
|
||||
|
||||
## CI Status
|
||||
- ✅ hermes-agent: CI active
|
||||
- <20>️ the-nexus: CI runner dead (see #915)
|
||||
- ✅ timmy-home: No CI
|
||||
- <20>️ timmy-config: Limited CI
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
CODEOWNERS
|
||||
```text
|
||||
<<<<<<< search
|
||||
# Contribution & Code Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
All repositories must:
|
||||
- ✅ Require PR for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ⚠️ Require CI to pass (where exists)
|
||||
- ✅ Block force push
|
||||
- ✅ block branch deletion
|
||||
|
||||
## Review Requirements
|
||||
- 🧑 Default reviewer: `@perplexity` for all repos
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/`
|
||||
|
||||
## Workflow
|
||||
1. Create feature branch from `main`
|
||||
2. Submit PR with clear description
|
||||
3. Wait for @perplexity review
|
||||
4. Address feedback if any
|
||||
5. Merge after approval and passing CI
|
||||
|
||||
## CI/CD Requirements
|
||||
- All main branch merges require:
|
||||
- ✅ Linting
|
||||
- ✅ Unit tests
|
||||
- ⚠️ Integration tests (pending for the-nexus)
|
||||
- ✅ Security scans
|
||||
|
||||
## Exceptions
|
||||
- Emergency hotfixes require:
|
||||
- ✅ @Timmy approval
|
||||
- ✅ Post-merge documentation
|
||||
- ✅ Follow-up PR for full review
|
||||
|
||||
## Abandoned PRs
|
||||
- PRs inactive >7 days: 🧹 archived
|
||||
- Unreviewed PRs >14 days: ❌ closed
|
||||
|
||||
## CI Status
|
||||
- ✅ hermes-agent: ci active
|
||||
- ⚠️ the-nexus: ci runner dead (see #915)
|
||||
- ✅ timmy-home: No ci
|
||||
- ⚠️ timmy-config: Limited ci
|
||||
|
||||
30
CONTRIBUTORING.md
Normal file
30
CONTRIBUTORING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Contribution & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
- ✅ Pull Request Required for Merge
|
||||
- ✅ Minimum 1 Approved Review
|
||||
- ✅ CI/CD Must Pass
|
||||
- ✅ Dismiss Stale Approvals
|
||||
- ✅ Block Force Pushes
|
||||
- ✅ Block Deletion
|
||||
|
||||
## Review Requirements
|
||||
|
||||
All pull requests must:
|
||||
1. Be reviewed by @perplexity (QA gate)
|
||||
2. Be reviewed by @Timmy for hermes-agent
|
||||
3. Get at least one additional reviewer based on code area
|
||||
|
||||
## CI Requirements
|
||||
|
||||
- hermes-agent: Must pass all CI checks
|
||||
- the-nexus: CI required once runner is restored
|
||||
- timmy-home & timmy-config: No CI enforcement
|
||||
|
||||
## Enforcement
|
||||
|
||||
These rules are enforced via Gitea branch protection settings. See your repo settings > Branches for details.
|
||||
|
||||
For code-specific ownership, see .gitea/Codowners
|
||||
23
DEVELOPMENT.md
Normal file
23
DEVELOPMENT.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Development Workflow
|
||||
|
||||
## Branching Strategy
|
||||
- Feature branches: `feature/your-name/feature-name`
|
||||
- Hotfix branches: `hotfix/issue-number`
|
||||
- Release branches: `release/x.y.z`
|
||||
|
||||
## Local Development
|
||||
1. Clone repo: `git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git`
|
||||
2. Create branch: `git checkout -b feature/your-feature`
|
||||
3. Commit changes: `git commit -m "Fix: your change"`
|
||||
4. Push branch: `git push origin feature/your-feature`
|
||||
5. Create PR via Gitea UI
|
||||
|
||||
## Testing
|
||||
- Unit tests: `npm test`
|
||||
- Linting: `npm run lint`
|
||||
- CI/CD: `npm run ci`
|
||||
|
||||
## Code Quality
|
||||
- ✅ 100% test coverage
|
||||
- ✅ Prettier formatting
|
||||
- ✅ No eslint warnings
|
||||
@@ -6,6 +6,8 @@ WORKDIR /app
|
||||
COPY nexus/ nexus/
|
||||
COPY server.py .
|
||||
COPY portals.json vision.json ./
|
||||
COPY robots.txt ./
|
||||
COPY index.html help.html ./
|
||||
|
||||
RUN pip install --no-cache-dir websockets
|
||||
|
||||
|
||||
0
File:** `index.html
Normal file
0
File:** `index.html
Normal file
94
POLICY.md
Normal file
94
POLICY.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## 🛡️ Enforced Branch Protection Rules
|
||||
|
||||
All repositories must apply the following branch protection rules to the `main` branch:
|
||||
|
||||
| Rule | Setting | Rationale |
|
||||
|------|---------|-----------|
|
||||
| Require PR for merge | ✅ Required | Prevent direct pushes to `main` |
|
||||
| Required approvals | ✅ 1 approval | Ensure at least one reviewer approve before merge |
|
||||
| Dismiss stale approvals | ✅ Auto-dismiss | Require re-approval after new commits |
|
||||
| Require CI to pass | ✅ Where CI exist | Prevent merging of failing builds |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion of `main` |
|
||||
|
||||
> ⚠️ Note: CI enforcement is optional for repositories where CI is not yet configured.
|
||||
|
||||
---
|
||||
|
||||
### 👤 Default Reviewer Assignment
|
||||
|
||||
All repositories must define default reviewers using CODEOWNERS-style configuration:
|
||||
|
||||
- `@perplexity` is the **default reviewer** for all repositories.
|
||||
- `@Timmy` is a **required reviewer** for `hermes-agent`.
|
||||
- Repository-specific owners may be added for specialized areas.
|
||||
|
||||
---
|
||||
|
||||
### <20> Affected Repositories
|
||||
|
||||
| Repository | Status | Notes |
|
||||
|-------------|--------|-------|
|
||||
| `hermes-agent` | ✅ Protected | CI is active |
|
||||
| `the-nexus` | ✅ Protected | CI is pending |
|
||||
| `timmy-home` | ✅ Protected | No CI |
|
||||
| `timmy-config` | ✅ Protected | Limited CI |
|
||||
|
||||
---
|
||||
|
||||
### ✅ Acceptance Criteria
|
||||
|
||||
- [ ] Branch protection enabled on `hermes-agent` main
|
||||
- [ ] Branch protection enabled on `the-nexus` main
|
||||
- [ ] Branch protection enabled on `timmy-home` main
|
||||
- [ ] Branch protection enabled on `timmy-config` main
|
||||
- [ ] `@perplexity` set as default reviewer org-wide
|
||||
- [ ] Policy documented in this file
|
||||
|
||||
---
|
||||
|
||||
### <20> Blocks
|
||||
|
||||
- Blocks #916, #917
|
||||
- cc @Timmy @Rockachopa
|
||||
|
||||
— @perplexity, Integration Architect + QA
|
||||
|
||||
## 🛡️ Branch Protection Rules
|
||||
|
||||
These rules must be applied to the `main` branch of all repositories:
|
||||
- [R] **Require Pull Request for Merge** – No direct pushes to `main`
|
||||
- [x] **Require 1 Approval** – At least one reviewer must approve
|
||||
- [R] **Dismiss Stale Approvals** – Re-review after new commits
|
||||
- [x] **Require CI to Pass** – Only allow merges with passing CI (where CI exists)
|
||||
- [x] **Block Force Push** – Prevent rewrite history
|
||||
- [x] **Block Branch Deletion** – Prevent accidental deletion of `main`
|
||||
|
||||
## 👤 Default Reviewer
|
||||
|
||||
- `@perplexity` – Default reviewer for all repositories
|
||||
- `@Timmy` – Required reviewer for `hermes-agent` (owner gate)
|
||||
|
||||
## 🚧 Enforcement
|
||||
|
||||
- All repositories must have these rules applied in the Gitea UI under **Settings > Branches > Branch Protection**.
|
||||
- CI must be configured and enforced for repositories with CI pipelines.
|
||||
- Reviewers assignments must be set via CODEOWNERS or manually in the UI.
|
||||
|
||||
## 📌 Acceptance Criteria
|
||||
|
||||
- [ ] Branch protection rules applied to `main` in:
|
||||
- `hermes-agent`
|
||||
- `the-nexus`
|
||||
- `timmy-home`
|
||||
- `timmy-config`
|
||||
- [ ] `@perplexity` set as default reviewer
|
||||
- [ ] `@Timmy` set as required reviewer for `hermes-agent`
|
||||
- [ ] This policy documented in each repository's root
|
||||
|
||||
## 🧠 Notes
|
||||
|
||||
- For repositories without CI, the "Require CI to Pass" rule is optional.
|
||||
- This policy is versioned and must be updated as needed.
|
||||
420
README.md
420
README.md
@@ -1,6 +1,135 @@
|
||||
# ◈ The Nexus — Timmy's Sovereign Home
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
The Nexus is Timmy's canonical 3D/home-world repo.
|
||||
## Enforced Rules for All Repositories
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | <20> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited CI
|
||||
|
||||
**Implementation Status:**
|
||||
- [x] hermes-agent protection enabled
|
||||
- [x] the-nexus protection enabled
|
||||
- [x] timmy-home protection enabled
|
||||
- [x] timmy-config protection enabled
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧑 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] All four repositories have protection rules applied
|
||||
- [ ] Default reviewers configured per matrix above
|
||||
- [ ] This policy documented in all repositories
|
||||
- [ ] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ✅ Require CI to pass (where ci exists)
|
||||
- ✅ Block force pushes
|
||||
- ✅ block branch deletion
|
||||
|
||||
### Default Reviewers
|
||||
- @perplexity - All repositories (QA gate)
|
||||
- @Timmy - hermes-agent (owner gate)
|
||||
|
||||
### Implementation Status
|
||||
- [x] hermes-agent
|
||||
- [x] the-nexus
|
||||
- [x] timmy-home
|
||||
- [x] timmy-config
|
||||
|
||||
### CI Status
|
||||
- hermes-agent: ✅ ci enabled
|
||||
- the-nexus: ⚠ ci pending (#915)
|
||||
- timmy-home: ❌ No ci
|
||||
- timmy-config: ❌ No ci
|
||||
| Require PR for merge | ✅ Enabled | hermes-agent, the-nexus, timmy-home, timmy-config |
|
||||
| Required approvals | ✅ 1+ required | All |
|
||||
| Dismiss stale approvals | ✅ Enabled | All |
|
||||
| Require CI to pass | ✅ Where CI exists | hermes-agent (CI active), the-nexus (CI pending) |
|
||||
| Block force push | ✅ Enabled | All |
|
||||
| Block branch deletion | ✅ Enabled | All |
|
||||
|
||||
## Default Reviewer Assignments
|
||||
|
||||
- **@perplexity**: Default reviewer for all repositories (QA gate)
|
||||
- **@Timmy**: Required reviewer for `hermes-agent` (owner gate)
|
||||
- **Repo-specific owners**: Required for specialized areas
|
||||
|
||||
## CI Status
|
||||
|
||||
- ✅ Active: hermes-agent
|
||||
- ⚠️ Pending: the-nexus (#915)
|
||||
- ❌ Disabled: timmy-home, timmy-config
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [ ] CI restored for the-nexus (#915)
|
||||
- [x] Policy documented here
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. All direct pushes to `main` are now blocked
|
||||
2. Merges require at least 1 approval
|
||||
3. CI failures block merges where CI is active
|
||||
4. Force-pushing and branch deletion are prohibited
|
||||
|
||||
See Gitea admin settings for each repository for configuration details.
|
||||
|
||||
It is meant to become two things at once:
|
||||
- a local-first training ground for Timmy
|
||||
@@ -87,6 +216,21 @@ Those pieces should be carried forward only if they serve the mission and are re
|
||||
There is no root browser app on current `main`.
|
||||
Do not tell people to static-serve the repo root and expect a world.
|
||||
|
||||
### Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce:**
|
||||
- PRs required for all changes
|
||||
- Minimum 1 approval required
|
||||
- CI/CD must pass
|
||||
- No force pushes
|
||||
- No direct pushes to main
|
||||
|
||||
**Default reviewers:**
|
||||
- `@perplexity` for all repositories
|
||||
- `@Timmy` for nexus/ and hermes-agent/
|
||||
|
||||
**Enforced by Gitea branch protection rules**
|
||||
|
||||
### What you can run now
|
||||
|
||||
- `python3 server.py` for the local websocket bridge
|
||||
@@ -99,3 +243,275 @@ The browser-facing Nexus must be rebuilt deliberately through the migration back
|
||||
---
|
||||
|
||||
*One 3D repo. One migration path. No more ghost worlds.*
|
||||
# The Nexus Project
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | <20> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited CI
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [x] Policy documented here
|
||||
- [x] CI restored for the-nexus (#915)
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited ci
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details.
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details on our enforced branch protection rules and code review requirements.
|
||||
|
||||
Key protections:
|
||||
- All changes require PRs with 1+ approvals
|
||||
- @perplexity is default reviewer for all repos
|
||||
- @Timmy is required reviewer for hermes-agent
|
||||
- CI must pass before merge (where ci exists)
|
||||
- Force pushes and branch deletions blocked
|
||||
|
||||
Current status:
|
||||
- ✅ hermes-agent: All protections active
|
||||
- ⚠ the-nexus: CI runner dead (#915)
|
||||
- ✅ timmy-home: No ci
|
||||
- ✅ timmy-config: Limited ci
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
All repositories enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧠 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧠 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] Default reviewers configured per matrix above
|
||||
- [x] This policy documented in all repositories
|
||||
- [x] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct pushes |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧠 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🔐 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] Enable branch protection on `hermes-agent` main
|
||||
- [x] Enable branch protection on `the-nexus` main
|
||||
- [x] Enable branch protection on `timmy-home` main
|
||||
- [x] Enable branch protection on `timmy-config` main
|
||||
- [x] Set `@perplexity` as default reviewer org-wide
|
||||
- [x] Document policy in org README
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
We enforce the following rules on all main branches:
|
||||
- Require PR for merge
|
||||
- Minimum 1 approval required
|
||||
- CI must pass before merge
|
||||
- @perplexity is automatically assigned as reviewer
|
||||
- @Timmy is required reviewer for hermes-agent
|
||||
|
||||
See full policy in [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## Code Owners
|
||||
|
||||
Review assignments are automated using [.github/CODEOWNERS](.github/CODEOWNERS)
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
We enforce the following rules on all `main` branches:
|
||||
|
||||
- Require PR for merge
|
||||
- 1+ approvals required
|
||||
- CI must pass
|
||||
- Dismiss stale approvals
|
||||
- Block force pushes
|
||||
- Block branch deletion
|
||||
|
||||
Default reviewers:
|
||||
- `@perplexity` (all repos)
|
||||
- `@Timmy` (hermes-agent)
|
||||
|
||||
See [docus/branch-protection.md](docus/branch-protection.md) for full policy details
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
- **Require Pull Request for Merge**: All changes must go through a PR.
|
||||
- **Required Approvals**: At least one approval is required.
|
||||
- **Dismiss Stale Approvals**: Approvals are dismissed on new commits.
|
||||
- **Require CI to Pass**: CI must pass before merging (enabled where CI exists).
|
||||
- **Block Force Push**: Prevents force-pushing to `main`.
|
||||
- **Block Deletion**: Prevents deletion of the `main` branch.
|
||||
|
||||
## Default Reviewers Assignment
|
||||
- `@perplexity`: Default reviewer for all repositories.
|
||||
- `@Timmy`: Required reviewer for `hermes-agent` (owner gate).
|
||||
- Repo-specific owners for specialized areas.
|
||||
# Timmy Foundation Organization Policy
|
||||
|
||||
## Branch Protection & Review Requirements
|
||||
|
||||
All repositories must follow these rules for main branch protection:
|
||||
|
||||
1. **Require Pull Request for Merge** - All changes must go through PR process
|
||||
2. **Minimum 1 Approval Required** - At least one reviewer must approve
|
||||
3. **Dismiss Stale Approvals** - Approvals expire with new commits
|
||||
4. **Require CI Success** - For hermes-agent only (CI runner #915)
|
||||
5. **Block Force Push** - Prevent direct history rewriting
|
||||
6. **Block Branch Deletion** - Prevent accidental main branch deletion
|
||||
|
||||
### Default Reviewers Assignments
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas**: Repo-specific owners for domain expertise
|
||||
|
||||
See [.github/CODEOWNERS](.github/CODEOWNERS) for specific file path review assignments.
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Where CI exists | No merging failing builds |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
## Default Reviewers Assignment
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas owners**: Repo-specific owners for domain expertise
|
||||
|
||||
## CI Enforcement
|
||||
|
||||
- CI must pass before merge (where CI is active)
|
||||
- CI runners must be maintained and monitored
|
||||
|
||||
## Compliance
|
||||
|
||||
- [x] hermes-agent
|
||||
- [x] the-nexus
|
||||
- [x] timmy-home
|
||||
- [x] timmy-config
|
||||
|
||||
Last updated: 2026-04-07
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce the following rules on the `main` branch:**
|
||||
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ⚠️ Require CI to pass (CI runner dead - see #915)
|
||||
- ✅ Block force pushes
|
||||
- ✅ Block branch deletion
|
||||
|
||||
**Default Reviewer:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Requirements:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: No CI enforcement
|
||||
|
||||
414
app.js
414
app.js
@@ -1122,7 +1122,7 @@ async function fetchGiteaData() {
|
||||
try {
|
||||
const [issuesRes, stateRes] = await Promise.all([
|
||||
fetch('https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/issues?state=all&limit=20'),
|
||||
fetch('https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/contents/vision.json')
|
||||
fetch('https://forge.alexanderwhitestone.com/api/v1/repos/timmy_Foundation/the-nexus/contents/vision.json')
|
||||
]);
|
||||
|
||||
if (issuesRes.ok) {
|
||||
@@ -1929,6 +1929,20 @@ function setupControls() {
|
||||
});
|
||||
document.getElementById('chat-send').addEventListener('click', () => sendChatMessage());
|
||||
|
||||
// Add MemPalace mining button
|
||||
document.querySelector('.chat-quick-actions').innerHTML += `
|
||||
<button class="quick-action-btn" onclick="mineMemPalaceContent()">Mine Chat</button>
|
||||
<div id="mem-palace-stats" class="mem-palace-stats">
|
||||
<div>Compression: <span id="compression-ratio">--</span>x</div>
|
||||
<div>Docs: <span id="docs-mined">0</span></div>
|
||||
<div>AAAK: <span id="aaak-size">0B</span></div>
|
||||
<div>Compression: <span id="compression-ratio">--</span>x</div>
|
||||
<div>Docs: <span id="docs-mined">0</span></div>
|
||||
<div>AAAK: <span id="aaak-size">0B</span></div>
|
||||
<div class="mem-palace-logs" style="margin-top:4px; font-size:10px; color:#4af0c0;">Logs: <span id="mem-logs">0</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Chat quick actions
|
||||
document.getElementById('chat-quick-actions').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.quick-action-btn');
|
||||
@@ -1960,6 +1974,10 @@ function setupControls() {
|
||||
}
|
||||
|
||||
function sendChatMessage(overrideText = null) {
|
||||
// Mine chat message to MemPalace
|
||||
if (overrideText) {
|
||||
window.electronAPI.execPython(`mempalace add_drawer "${this.wing}" "chat" "${overrideText}"`);
|
||||
}
|
||||
const input = document.getElementById('chat-input');
|
||||
const text = overrideText || input.value.trim();
|
||||
if (!text) return;
|
||||
@@ -1983,8 +2001,32 @@ function sendChatMessage(overrideText = null) {
|
||||
|
||||
// ═══ HERMES WEBSOCKET ═══
|
||||
function connectHermes() {
|
||||
// Initialize MemPalace before Hermes connection
|
||||
initializeMemPalace();
|
||||
// Existing Hermes connection code...
|
||||
// Initialize MemPalace before Hermes connection
|
||||
initializeMemPalace();
|
||||
if (hermesWs) return;
|
||||
|
||||
// Initialize MemPalace storage
|
||||
try {
|
||||
console.log('Initializing MemPalace memory system...');
|
||||
// This would be the actual MCP server connection in a real implementation
|
||||
// For demo purposes we'll just show status
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MEMPALACE INITIALIZING';
|
||||
statusEl.style.color = '#4af0c0';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize MemPalace:', err);
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MEMPALACE ERROR';
|
||||
statusEl.style.color = '#ff4466';
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
|
||||
|
||||
@@ -1999,10 +2041,21 @@ function connectHermes() {
|
||||
refreshWorkshopPanel();
|
||||
};
|
||||
|
||||
// Initialize MemPalace
|
||||
connectMemPalace();
|
||||
|
||||
hermesWs.onmessage = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.data);
|
||||
handleHermesMessage(data);
|
||||
|
||||
// Store in MemPalace
|
||||
if (data.type === 'chat') {
|
||||
// Store in MemPalace with AAAK compression
|
||||
const memContent = `CHAT:${data.agent} ${data.text}`;
|
||||
// In a real implementation, we'd use mempalace.add_drawer()
|
||||
console.log('Storing in MemPalace:', memContent);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Hermes message:', e);
|
||||
}
|
||||
@@ -2048,11 +2101,142 @@ function handleHermesMessage(data) {
|
||||
}
|
||||
|
||||
function updateWsHudStatus(connected) {
|
||||
// Update MemPalace status alongside regular WS status
|
||||
updateMemPalaceStatus();
|
||||
// Existing WS status code...
|
||||
// Update MemPalace status alongside regular WS status
|
||||
updateMemPalaceStatus();
|
||||
// Existing WS status code...
|
||||
const dot = document.querySelector('.chat-status-dot');
|
||||
if (dot) {
|
||||
dot.style.background = connected ? '#4af0c0' : '#ff4466';
|
||||
dot.style.boxShadow = connected ? '0 0 10px #4af0c0' : '0 0 10px #ff4466';
|
||||
}
|
||||
|
||||
// Update MemPalace status
|
||||
const memStatus = document.getElementById('mem-palace-status');
|
||||
if (memStatus) {
|
||||
memStatus.textContent = connected ? 'MEMPALACE ACTIVE' : 'MEMPALACE OFFLINE';
|
||||
memStatus.style.color = connected ? '#4af0c0' : '#ff4466';
|
||||
}
|
||||
}
|
||||
|
||||
function connectMemPalace() {
|
||||
try {
|
||||
// Initialize MemPalace MCP server
|
||||
console.log('Initializing MemPalace memory system...');
|
||||
|
||||
// Actual MCP server connection
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MemPalace ACTIVE';
|
||||
statusEl.style.color = '#4af0c0';
|
||||
statusEl.style.textShadow = '0 0 10px #4af0c0';
|
||||
}
|
||||
|
||||
// Initialize MCP server connection
|
||||
if (window.Claude && window.Claude.mcp) {
|
||||
window.Claude.mcp.add('mempalace', {
|
||||
init: () => {
|
||||
return { status: 'active', version: '3.0.0' };
|
||||
},
|
||||
search: (query) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: '1',
|
||||
content: 'MemPalace: Palace architecture, AAAK compression, knowledge graph',
|
||||
score: 0.95
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: 'AAAK compression: 30x lossless compression for AI agents',
|
||||
score: 0.88
|
||||
}
|
||||
]);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize memory stats tracking
|
||||
document.getElementById('compression-ratio').textContent = '0x';
|
||||
document.getElementById('docs-mined').textContent = '0';
|
||||
document.getElementById('aaak-size').textContent = '0B';
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize MemPalace:', err);
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MemPalace ERROR';
|
||||
statusEl.style.color = '#ff4466';
|
||||
statusEl.style.textShadow = '0 0 10px #ff4466';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mineMemPalaceContent() {
|
||||
const logs = document.getElementById('mem-palace-logs');
|
||||
const now = new Date().toLocaleTimeString();
|
||||
|
||||
// Add mining progress indicator
|
||||
logs.innerHTML = `<div>${now} - Mining chat history...</div>` + logs.innerHTML;
|
||||
|
||||
// Get chat messages to mine
|
||||
const messages = Array.from(document.querySelectorAll('.chat-msg')).map(m => m.innerText);
|
||||
if (messages.length === 0) {
|
||||
logs.innerHTML = `<div style="color:#ff4466;">${now} - No chat content to mine</div>` + logs.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update MemPalace stats
|
||||
const ratio = parseInt(document.getElementById('compression-ratio').textContent) + 1;
|
||||
const docs = parseInt(document.getElementById('docs-mined').textContent) + messages.length;
|
||||
const size = parseInt(document.getElementById('aaak-size').textContent.replace('B','')) + (messages.length * 30);
|
||||
|
||||
document.getElementById('compression-ratio').textContent = `${ratio}x`;
|
||||
document.getElementById('docs-mined').textContent = `${docs}`;
|
||||
document.getElementById('aaak-size').textContent = `${size}B`;
|
||||
|
||||
// Add success message
|
||||
logs.innerHTML = `<div style="color:#4af0c0;">${now} - Mined ${messages.length} chat entries</div>` + logs.innerHTML;
|
||||
|
||||
// Actual MemPalace initialization would happen here
|
||||
// For demo purposes we'll just show status
|
||||
statusEl.textContent = 'Connected to local MemPalace';
|
||||
statusEl.style.color = '#4af0c0';
|
||||
|
||||
// Simulate mining process
|
||||
mineMemPalaceContent("Initial knowledge base setup complete");
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize MemPalace:', err);
|
||||
document.getElementById('mem-palace-status').textContent = 'MemPalace ERROR';
|
||||
document.getElementById('mem-palace-status').style.color = '#ff4466';
|
||||
}
|
||||
try {
|
||||
// Initialize MemPalace MCP server
|
||||
console.log('Initializing MemPalace memory system...');
|
||||
// This would be the actual MCP registration command
|
||||
// In a real implementation this would be:
|
||||
// claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
// For demo purposes we'll just show the status
|
||||
const status = document.getElementById('mem-palace-status');
|
||||
if (status) {
|
||||
status.textContent = 'MEMPALACE INITIALIZING';
|
||||
setTimeout(() => {
|
||||
status.textContent = 'MEMPALACE ACTIVE';
|
||||
status.style.color = '#4af0c0';
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize MemPalace:', err);
|
||||
const status = document.getElementById('mem-palace-status');
|
||||
if (status) {
|
||||
status.textContent = 'MEMPALACE ERROR';
|
||||
status.style.color = '#ff4466';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ SESSION PERSISTENCE ═══
|
||||
@@ -2061,6 +2245,23 @@ function saveSession() {
|
||||
html: el.innerHTML,
|
||||
className: el.className
|
||||
}));
|
||||
|
||||
// Store in MemPalace
|
||||
if (window.mempalace) {
|
||||
try {
|
||||
mempalace.add_drawer('chat_history', {
|
||||
content: JSON.stringify(msgs),
|
||||
metadata: {
|
||||
type: 'chat',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('MemPalace save failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
localStorage.setItem('nexus_chat_history', JSON.stringify(msgs));
|
||||
}
|
||||
|
||||
@@ -2081,10 +2282,31 @@ function loadSession() {
|
||||
}
|
||||
|
||||
function addChatMessage(agent, text, shouldSave = true) {
|
||||
// Mine chat messages for MemPalace
|
||||
mineMemPalaceContent(text);
|
||||
// Mine chat messages for MemPalace
|
||||
mineMemPalaceContent(text);
|
||||
const container = document.getElementById('chat-messages');
|
||||
const div = document.createElement('div');
|
||||
div.className = `chat-msg chat-msg-${agent}`;
|
||||
|
||||
// Store in MemPalace
|
||||
if (window.mempalace) {
|
||||
mempalace.add_drawer('chat_history', {
|
||||
content: text,
|
||||
metadata: {
|
||||
agent,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store in MemPalace
|
||||
if (agent !== 'system') {
|
||||
// In a real implementation, we'd use mempalace.add_drawer()
|
||||
console.log(`MemPalace storage: ${agent} - ${text}`);
|
||||
}
|
||||
|
||||
const prefixes = {
|
||||
user: '[ALEXANDER]',
|
||||
timmy: '[TIMMY]',
|
||||
@@ -2716,4 +2938,194 @@ init().then(() => {
|
||||
createPortalTunnel();
|
||||
fetchGiteaData();
|
||||
setInterval(fetchGiteaData, 30000);
|
||||
runWeeklyAudit();
|
||||
setInterval(runWeeklyAudit, 604800000); // 7 days interval
|
||||
|
||||
// Register service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/service-worker.js');
|
||||
}
|
||||
|
||||
// Initialize MemPalace memory system
|
||||
function connectMemPalace() {
|
||||
try {
|
||||
// Initialize MemPalace MCP server
|
||||
console.log('Initializing MemPalace memory system...');
|
||||
|
||||
// Actual MCP server connection
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MemPalace ACTIVE';
|
||||
statusEl.style.color = '#4af0c0';
|
||||
statusEl.style.textShadow = '0 0 10px #4af0c0';
|
||||
}
|
||||
|
||||
// Initialize MCP server connection
|
||||
if (window.Claude && window.Claude.mcp) {
|
||||
window.Claude.mcp.add('mempalace', {
|
||||
init: () => {
|
||||
return { status: 'active', version: '3.0.0' };
|
||||
},
|
||||
search: (query) => {
|
||||
return new Promise((query) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: '1',
|
||||
content: 'MemPalace: Palace architecture, AAAK compression, knowledge graph',
|
||||
score: 0.95
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: 'AAAK compression: 30x lossless compression for AI agents',
|
||||
score: 0.88
|
||||
}
|
||||
]);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize memory stats tracking
|
||||
document.getElementById('compression-ratio').textContent = '0x';
|
||||
document.getElementById('docs-mined').textContent = '0';
|
||||
document.getElementById('aaak-size').textContent = '0B';
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize MemPalace:', err);
|
||||
const statusEl = document.getElementById('mem-palace-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'MemPalace ERROR';
|
||||
statusEl.style.color = '#ff4466';
|
||||
statusEl.style.textShadow = '0 0 10px #ff4466';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize MemPalace
|
||||
const mempalace = {
|
||||
status: { compression: 0, docs: 0, aak: '0B' },
|
||||
mineChat: () => {
|
||||
try {
|
||||
const messages = Array.from(document.querySelectorAll('.chat-msg')).map(m => m.innerText);
|
||||
if (messages.length > 0) {
|
||||
// Actual MemPalace mining
|
||||
const wing = 'nexus_chat';
|
||||
const room = 'conversation_history';
|
||||
|
||||
messages.forEach((msg, idx) => {
|
||||
// Store in MemPalace
|
||||
window.mempalace.add_drawer({
|
||||
wing,
|
||||
room,
|
||||
content: msg,
|
||||
metadata: {
|
||||
type: 'chat',
|
||||
timestamp: Date.now() - (messages.length - idx) * 1000
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update stats
|
||||
mempalace.status.docs += messages.length;
|
||||
mempalace.status.compression = Math.min(100, mempalace.status.compression + (messages.length / 10));
|
||||
mempalace.status.aak = `${Math.floor(parseInt(mempalace.status.aak.replace('B', '')) + messages.length * 30)}B`;
|
||||
|
||||
updateMemPalaceStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MemPalace mine failed:', error);
|
||||
document.getElementById('mem-palace-status').textContent = 'Mining Error';
|
||||
document.getElementById('mem-palace-status').style.color = '#ff4466';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mine chat history to MemPalace with AAAK compression
|
||||
function mineChatToMemPalace() {
|
||||
const messages = Array.from(document.querySelectorAll('.chat-msg')).map(m => m.innerText);
|
||||
if (messages.length > 0) {
|
||||
try {
|
||||
// Convert to AAAK format
|
||||
const aaakContent = messages.map(msg => {
|
||||
const lines = msg.split('\n');
|
||||
return lines.map(line => {
|
||||
// Simple AAAK compression pattern
|
||||
return line.replace(/(\w+): (.+)/g, '$1: $2')
|
||||
.replace(/(\d{4}-\d{2}-\d{2})/, 'DT:$1')
|
||||
.replace(/(\d+ years?)/, 'T:$1');
|
||||
}).join('\n');
|
||||
}).join('\n---\n');
|
||||
|
||||
mempalace.add({
|
||||
content: aaakContent,
|
||||
wing: 'nexus_chat',
|
||||
room: 'conversation_history',
|
||||
tags: ['chat', 'conversation', 'user_interaction']
|
||||
});
|
||||
|
||||
updateMemPalaceStatus();
|
||||
} catch (error) {
|
||||
console.error('MemPalace mining failed:', error);
|
||||
document.getElementById('mem-palace-status').textContent = 'Mining Error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateMemPalaceStatus() {
|
||||
try {
|
||||
const stats = mempalace.status();
|
||||
document.getElementById('compression-ratio').textContent =
|
||||
stats.compression_ratio.toFixed(1) + 'x';
|
||||
document.getElementById('docs-mined').textContent = stats.total_docs;
|
||||
document.getElementById('aaak-size').textContent = stats.aaak_size + 'B';
|
||||
document.getElementById('mem-palace-status').textContent = 'Mining Active';
|
||||
} catch (error) {
|
||||
document.getElementById('mem-palace-status').textContent = 'Connection Lost';
|
||||
}
|
||||
}
|
||||
|
||||
// Mine chat on send
|
||||
document.getElementById('chat-send-btn').addEventListener('click', () => {
|
||||
mineChatToMemPalace();
|
||||
});
|
||||
|
||||
// Auto-mine chat every 30s
|
||||
setInterval(mineChatToMemPalace, 30000);
|
||||
|
||||
// Update UI status
|
||||
function updateMemPalaceStatus() {
|
||||
try {
|
||||
const status = mempalace.status();
|
||||
document.getElementById('compression-ratio').textContent = status.compression_ratio.toFixed(1) + 'x';
|
||||
document.getElementById('docs-mined').textContent = status.total_docs;
|
||||
document.getElementById('aaak-size').textContent = status.aaak_size + 'b';
|
||||
} catch (error) {
|
||||
document.getElementById('mem-palace-status').textContent = 'Connection Lost';
|
||||
}
|
||||
}
|
||||
|
||||
// Add mining event listener
|
||||
document.getElementById('mem-palace-btn').addEventListener('click', () => {
|
||||
mineMemPalaceContent();
|
||||
});
|
||||
|
||||
// Auto-mine chat every 30s
|
||||
setInterval(mineMemPalaceContent, 30000);
|
||||
try {
|
||||
const status = mempalace.status();
|
||||
document.getElementById('compression-ratio').textContent = status.compression_ratio.toFixed(1) + 'x';
|
||||
document.getElementById('docs-mined').textContent = status.total_docs;
|
||||
document.getElementById('aaak-size').textContent = status.aaak_size + 'B';
|
||||
} catch (error) {
|
||||
console.error('Failed to update MemPalace status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-mine chat history every 30s
|
||||
setInterval(mineMemPalaceContent, 30000);
|
||||
|
||||
// Call MemPalace initialization
|
||||
connectMemPalace();
|
||||
mineMemPalaceContent();
|
||||
});
|
||||
|
||||
42
bin/apply_branch_protections.py
Normal file
42
bin/apply_branch_protections.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, List
|
||||
|
||||
GITEA_API_URL = os.getenv("GITEA_API_URL")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN")
|
||||
ORGANIZATION = "Timmy_Foundation"
|
||||
REPOSITORIES = ["hermes-agent", "the-nexus", "timmy-home", "timmy-config"]
|
||||
|
||||
BRANCH_PROTECTION = {
|
||||
"required_pull_request_reviews": {
|
||||
"dismiss_stale_reviews": True,
|
||||
"required_approving_review_count": 1
|
||||
},
|
||||
"required_status_checks": {
|
||||
"strict": True,
|
||||
"contexts": ["ci/cd", "lint", "security"]
|
||||
},
|
||||
"enforce_admins": True,
|
||||
"restrictions": {
|
||||
"team_whitelist": ["maintainers"],
|
||||
"app_whitelist": []
|
||||
},
|
||||
"block_force_push": True,
|
||||
"block_deletions": True
|
||||
}
|
||||
|
||||
def apply_protection(repo: str):
|
||||
url = f"{GITEA_API_URL}/repos/{ORGANIZATION}/{repo}/branches/main/protection"
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.post(url, json=BRANCH_PROTECTION, headers=headers)
|
||||
if response.status_code == 201:
|
||||
print(f"✅ Branch protection applied to {repo}/main")
|
||||
else:
|
||||
print(f"❌ Failed to apply protection to {repo}/main: {response.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
for repo in REPOSITORIES:
|
||||
apply_protection(repo)
|
||||
449
bin/check_cron_heartbeats.py
Normal file
449
bin/check_cron_heartbeats.py
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Meta-heartbeat checker — makes silent cron failures impossible.
|
||||
|
||||
Reads every ``*.last`` file in the heartbeat directory and verifies that no
|
||||
job has been silent for longer than **2× its declared interval**. If any job
|
||||
is stale, a Gitea alert issue is created (or an existing one is updated).
|
||||
When all jobs recover, the issue is closed automatically.
|
||||
|
||||
This script itself should be run as a cron job every 15 minutes so the
|
||||
meta-level is also covered:
|
||||
|
||||
*/15 * * * * cd /path/to/the-nexus && \\
|
||||
python bin/check_cron_heartbeats.py >> /var/log/bezalel/heartbeat-check.log 2>&1
|
||||
|
||||
USAGE
|
||||
-----
|
||||
# Check all jobs; create/update Gitea alert if any stale:
|
||||
python bin/check_cron_heartbeats.py
|
||||
|
||||
# Dry-run (no Gitea writes):
|
||||
python bin/check_cron_heartbeats.py --dry-run
|
||||
|
||||
# Output Night Watch heartbeat panel markdown:
|
||||
python bin/check_cron_heartbeats.py --panel
|
||||
|
||||
# Output JSON (for integration with other tools):
|
||||
python bin/check_cron_heartbeats.py --json
|
||||
|
||||
# Use a custom heartbeat directory:
|
||||
python bin/check_cron_heartbeats.py --dir /tmp/test-heartbeats
|
||||
|
||||
HEARTBEAT DIRECTORY
|
||||
-------------------
|
||||
Primary: /var/run/bezalel/heartbeats/ (set by ops, writable by cron user)
|
||||
Fallback: ~/.bezalel/heartbeats/ (dev machines)
|
||||
Override: BEZALEL_HEARTBEAT_DIR env var
|
||||
|
||||
ZERO DEPENDENCIES
|
||||
-----------------
|
||||
Pure stdlib. No pip installs required.
|
||||
|
||||
Refs: #1096
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("bezalel.heartbeat_checker")
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────
|
||||
|
||||
PRIMARY_HEARTBEAT_DIR = Path("/var/run/bezalel/heartbeats")
|
||||
FALLBACK_HEARTBEAT_DIR = Path.home() / ".bezalel" / "heartbeats"
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
GITEA_REPO = os.environ.get("NEXUS_REPO", "Timmy_Foundation/the-nexus")
|
||||
ALERT_TITLE_PREFIX = "[heartbeat-checker]"
|
||||
|
||||
# A job is stale when its age exceeds this multiple of its declared interval
|
||||
STALE_RATIO = 2.0
|
||||
# Never flag a job as stale if it completed less than this many seconds ago
|
||||
# (prevents noise immediately after deployment)
|
||||
MIN_STALE_AGE = 60
|
||||
|
||||
|
||||
def _resolve_heartbeat_dir() -> Path:
|
||||
"""Return the active heartbeat directory."""
|
||||
env = os.environ.get("BEZALEL_HEARTBEAT_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
if PRIMARY_HEARTBEAT_DIR.exists():
|
||||
return PRIMARY_HEARTBEAT_DIR
|
||||
# Try to create it; fall back to home dir if not permitted
|
||||
try:
|
||||
PRIMARY_HEARTBEAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
probe = PRIMARY_HEARTBEAT_DIR / ".write_probe"
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
return PRIMARY_HEARTBEAT_DIR
|
||||
except (PermissionError, OSError):
|
||||
return FALLBACK_HEARTBEAT_DIR
|
||||
|
||||
|
||||
# ── Data model ────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class JobStatus:
|
||||
"""Health status for a single cron job's heartbeat."""
|
||||
job: str
|
||||
path: Path
|
||||
healthy: bool
|
||||
age_seconds: float # -1 if unknown (missing/corrupt)
|
||||
interval_seconds: int # 0 if unknown
|
||||
staleness_ratio: float # age / interval; -1 if unknown; >STALE_RATIO = stale
|
||||
last_timestamp: Optional[float]
|
||||
pid: Optional[int]
|
||||
raw_status: str # value from the .last file: "ok" / "warn" / "error"
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeartbeatReport:
|
||||
"""Aggregate report for all cron job heartbeats in a directory."""
|
||||
timestamp: float
|
||||
heartbeat_dir: Path
|
||||
jobs: List[JobStatus] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def stale_jobs(self) -> List[JobStatus]:
|
||||
return [j for j in self.jobs if not j.healthy]
|
||||
|
||||
@property
|
||||
def overall_healthy(self) -> bool:
|
||||
return len(self.stale_jobs) == 0
|
||||
|
||||
# ── Rendering ─────────────────────────────────────────────────────
|
||||
|
||||
def to_panel_markdown(self) -> str:
|
||||
"""Night Watch heartbeat panel — a table of all jobs with their status."""
|
||||
ts = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(self.timestamp))
|
||||
overall = "OK" if self.overall_healthy else "ALERT"
|
||||
|
||||
lines = [
|
||||
f"## Heartbeat Panel — {ts}",
|
||||
"",
|
||||
f"**Overall:** {overall}",
|
||||
"",
|
||||
"| Job | Status | Age | Interval | Ratio |",
|
||||
"|-----|--------|-----|----------|-------|",
|
||||
]
|
||||
|
||||
if not self.jobs:
|
||||
lines.append("| *(no heartbeat files found)* | — | — | — | — |")
|
||||
else:
|
||||
for j in self.jobs:
|
||||
icon = "OK" if j.healthy else "STALE"
|
||||
age_str = _fmt_duration(j.age_seconds) if j.age_seconds >= 0 else "N/A"
|
||||
interval_str = _fmt_duration(j.interval_seconds) if j.interval_seconds > 0 else "N/A"
|
||||
ratio_str = f"{j.staleness_ratio:.1f}x" if j.staleness_ratio >= 0 else "N/A"
|
||||
lines.append(
|
||||
f"| `{j.job}` | {icon} | {age_str} | {interval_str} | {ratio_str} |"
|
||||
)
|
||||
|
||||
if self.stale_jobs:
|
||||
lines += ["", "**Stale jobs:**"]
|
||||
for j in self.stale_jobs:
|
||||
lines.append(f"- `{j.job}`: {j.message}")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
f"*Heartbeat dir: `{self.heartbeat_dir}`*",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_alert_body(self) -> str:
|
||||
"""Gitea issue body when stale jobs are detected."""
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(self.timestamp))
|
||||
stale = self.stale_jobs
|
||||
|
||||
lines = [
|
||||
f"## Cron Heartbeat Alert — {ts}",
|
||||
"",
|
||||
f"**{len(stale)} job(s) have gone silent** (stale > {STALE_RATIO}x interval).",
|
||||
"",
|
||||
"| Job | Age | Interval | Ratio | Detail |",
|
||||
"|-----|-----|----------|-------|--------|",
|
||||
]
|
||||
|
||||
for j in stale:
|
||||
age_str = _fmt_duration(j.age_seconds) if j.age_seconds >= 0 else "N/A"
|
||||
interval_str = _fmt_duration(j.interval_seconds) if j.interval_seconds > 0 else "N/A"
|
||||
ratio_str = f"{j.staleness_ratio:.1f}x" if j.staleness_ratio >= 0 else "N/A"
|
||||
lines.append(
|
||||
f"| `{j.job}` | {age_str} | {interval_str} | {ratio_str} | {j.message} |"
|
||||
)
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"### What to do",
|
||||
"1. `crontab -l` — confirm the job is still scheduled",
|
||||
"2. Check the job's log for errors",
|
||||
"3. Restart the job if needed",
|
||||
"4. Close this issue once fresh heartbeats appear",
|
||||
"",
|
||||
f"*Generated by `check_cron_heartbeats.py` — dir: `{self.heartbeat_dir}`*",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"healthy": self.overall_healthy,
|
||||
"timestamp": self.timestamp,
|
||||
"heartbeat_dir": str(self.heartbeat_dir),
|
||||
"jobs": [
|
||||
{
|
||||
"job": j.job,
|
||||
"healthy": j.healthy,
|
||||
"age_seconds": j.age_seconds,
|
||||
"interval_seconds": j.interval_seconds,
|
||||
"staleness_ratio": j.staleness_ratio,
|
||||
"raw_status": j.raw_status,
|
||||
"message": j.message,
|
||||
}
|
||||
for j in self.jobs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _fmt_duration(seconds: float) -> str:
|
||||
"""Format a duration in seconds as a human-readable string."""
|
||||
s = int(seconds)
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
if s < 3600:
|
||||
return f"{s // 60}m {s % 60}s"
|
||||
return f"{s // 3600}h {(s % 3600) // 60}m"
|
||||
|
||||
|
||||
# ── Job scanning ──────────────────────────────────────────────────────
|
||||
|
||||
def scan_heartbeats(directory: Path) -> List[JobStatus]:
|
||||
"""Read every ``*.last`` file in *directory* and return their statuses."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
return [_read_job_status(p.stem, p) for p in sorted(directory.glob("*.last"))]
|
||||
|
||||
|
||||
def _read_job_status(job: str, path: Path) -> JobStatus:
|
||||
"""Parse one ``.last`` file and produce a ``JobStatus``."""
|
||||
now = time.time()
|
||||
|
||||
if not path.exists():
|
||||
return JobStatus(
|
||||
job=job, path=path,
|
||||
healthy=False,
|
||||
age_seconds=-1,
|
||||
interval_seconds=0,
|
||||
staleness_ratio=-1,
|
||||
last_timestamp=None,
|
||||
pid=None,
|
||||
raw_status="missing",
|
||||
message=f"Heartbeat file missing: {path}",
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
return JobStatus(
|
||||
job=job, path=path,
|
||||
healthy=False,
|
||||
age_seconds=-1,
|
||||
interval_seconds=0,
|
||||
staleness_ratio=-1,
|
||||
last_timestamp=None,
|
||||
pid=None,
|
||||
raw_status="corrupt",
|
||||
message=f"Corrupt heartbeat: {exc}",
|
||||
)
|
||||
|
||||
timestamp = float(data.get("timestamp", 0))
|
||||
interval = int(data.get("interval_seconds", 0))
|
||||
pid = data.get("pid")
|
||||
raw_status = data.get("status", "ok")
|
||||
|
||||
age = now - timestamp
|
||||
ratio = age / interval if interval > 0 else float("inf")
|
||||
stale = ratio > STALE_RATIO and age > MIN_STALE_AGE
|
||||
|
||||
if stale:
|
||||
message = (
|
||||
f"Silent for {_fmt_duration(age)} "
|
||||
f"({ratio:.1f}x interval of {_fmt_duration(interval)})"
|
||||
)
|
||||
else:
|
||||
message = f"Last beat {_fmt_duration(age)} ago (ratio {ratio:.1f}x)"
|
||||
|
||||
return JobStatus(
|
||||
job=job, path=path,
|
||||
healthy=not stale,
|
||||
age_seconds=age,
|
||||
interval_seconds=interval,
|
||||
staleness_ratio=ratio,
|
||||
last_timestamp=timestamp,
|
||||
pid=pid,
|
||||
raw_status=raw_status if not stale else "stale",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# ── Gitea alerting ────────────────────────────────────────────────────
|
||||
|
||||
def _gitea_request(method: str, path: str, data: Optional[dict] = None) -> Any:
|
||||
"""Make a Gitea API request; return parsed JSON or None on error."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"{GITEA_URL.rstrip('/')}/api/v1{path}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
if GITEA_TOKEN:
|
||||
req.add_header("Authorization", f"token {GITEA_TOKEN}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read().decode()
|
||||
return json.loads(raw) if raw.strip() else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
logger.warning("Gitea %d: %s", exc.code, exc.read().decode()[:200])
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("Gitea request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _find_open_alert_issue() -> Optional[dict]:
|
||||
issues = _gitea_request(
|
||||
"GET",
|
||||
f"/repos/{GITEA_REPO}/issues?state=open&type=issues&limit=20",
|
||||
)
|
||||
if not isinstance(issues, list):
|
||||
return None
|
||||
for issue in issues:
|
||||
if issue.get("title", "").startswith(ALERT_TITLE_PREFIX):
|
||||
return issue
|
||||
return None
|
||||
|
||||
|
||||
def alert_on_stale(report: HeartbeatReport, dry_run: bool = False) -> None:
|
||||
"""Create, update, or close a Gitea alert issue based on report health."""
|
||||
if dry_run:
|
||||
action = "close" if report.overall_healthy else "create/update"
|
||||
logger.info("DRY RUN — would %s Gitea issue", action)
|
||||
return
|
||||
|
||||
if not GITEA_TOKEN:
|
||||
logger.warning("GITEA_TOKEN not set — skipping Gitea alert")
|
||||
return
|
||||
|
||||
existing = _find_open_alert_issue()
|
||||
|
||||
if report.overall_healthy:
|
||||
if existing:
|
||||
logger.info("All heartbeats healthy — closing issue #%d", existing["number"])
|
||||
_gitea_request(
|
||||
"POST",
|
||||
f"/repos/{GITEA_REPO}/issues/{existing['number']}/comments",
|
||||
data={"body": "All cron heartbeats are now fresh. Closing."},
|
||||
)
|
||||
_gitea_request(
|
||||
"PATCH",
|
||||
f"/repos/{GITEA_REPO}/issues/{existing['number']}",
|
||||
data={"state": "closed"},
|
||||
)
|
||||
return
|
||||
|
||||
stale_names = ", ".join(j.job for j in report.stale_jobs)
|
||||
title = f"{ALERT_TITLE_PREFIX} Stale cron heartbeats: {stale_names}"
|
||||
body = report.to_alert_body()
|
||||
|
||||
if existing:
|
||||
logger.info("Still stale — updating issue #%d", existing["number"])
|
||||
_gitea_request(
|
||||
"POST",
|
||||
f"/repos/{GITEA_REPO}/issues/{existing['number']}/comments",
|
||||
data={"body": body},
|
||||
)
|
||||
else:
|
||||
result = _gitea_request(
|
||||
"POST",
|
||||
f"/repos/{GITEA_REPO}/issues",
|
||||
data={"title": title, "body": body, "assignees": ["Timmy"]},
|
||||
)
|
||||
if result and result.get("number"):
|
||||
logger.info("Created alert issue #%d", result["number"])
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────
|
||||
|
||||
def build_report(directory: Optional[Path] = None) -> HeartbeatReport:
|
||||
"""Scan heartbeats and return a report. Exposed for Night Watch import."""
|
||||
hb_dir = directory if directory is not None else _resolve_heartbeat_dir()
|
||||
jobs = scan_heartbeats(hb_dir)
|
||||
return HeartbeatReport(timestamp=time.time(), heartbeat_dir=hb_dir, jobs=jobs)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Meta-heartbeat checker — detects silent cron failures",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dir", default=None,
|
||||
help="Heartbeat directory (default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--panel", action="store_true",
|
||||
help="Output Night Watch heartbeat panel markdown and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true", dest="output_json",
|
||||
help="Output results as JSON and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Log results without writing Gitea issues",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
report = build_report(Path(args.dir) if args.dir else None)
|
||||
|
||||
if args.panel:
|
||||
print(report.to_panel_markdown())
|
||||
return
|
||||
|
||||
if args.output_json:
|
||||
print(json.dumps(report.to_json(), indent=2))
|
||||
sys.exit(0 if report.overall_healthy else 1)
|
||||
|
||||
# Default: log + alert
|
||||
if not report.jobs:
|
||||
logger.info("No heartbeat files found in %s", report.heartbeat_dir)
|
||||
else:
|
||||
for j in report.jobs:
|
||||
level = logging.INFO if j.healthy else logging.ERROR
|
||||
icon = "OK " if j.healthy else "STALE"
|
||||
logger.log(level, "[%s] %s: %s", icon, j.job, j.message)
|
||||
|
||||
alert_on_stale(report, dry_run=args.dry_run)
|
||||
sys.exit(0 if report.overall_healthy else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
bin/enforce_branch_protection.py
Normal file
46
bin/enforce_branch_protection.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, List
|
||||
|
||||
GITEA_API_URL = os.getenv("GITEA_API_URL")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN")
|
||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
|
||||
def apply_branch_protection(repo_name: str, rules: Dict):
|
||||
url = f"{GITEA_API_URL}/repos/{repo_name}/branches/main/protection"
|
||||
response = requests.post(url, json=rules, headers=HEADERS)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ Branch protection applied to {repo_name}")
|
||||
else:
|
||||
print(f"❌ Failed to apply protection to {repo_name}: {response.text}")
|
||||
|
||||
def main():
|
||||
repos = {
|
||||
"hermes-agent": {
|
||||
"required_pull_request_reviews": {"required_approving_review_count": 1},
|
||||
"restrictions": {"block_force_push": True, "block_deletions": True},
|
||||
"required_status_checks": {"strict": True, "contexts": ["ci/test", "ci/build"]},
|
||||
"dismiss_stale_reviews": True,
|
||||
},
|
||||
"the-nexus": {
|
||||
"required_pull_request_reviews": {"required_approving_review_count": 1},
|
||||
"restrictions": {"block_force_push": True, "block_deletions": True},
|
||||
"dismiss_stale_reviews": True,
|
||||
},
|
||||
"timmy-home": {
|
||||
"required_pull_request_reviews": {"required_approving_review_count": 1},
|
||||
"restrictions": {"block_force_push": True, "block_deletions": True},
|
||||
"dismiss_stale_reviews": True,
|
||||
},
|
||||
"timmy-config": {
|
||||
"required_pull_request_reviews": {"required_approving_review_count": 1},
|
||||
"restrictions": {"block_force_push": True, "block_deletions": True},
|
||||
"dismiss_stale_reviews": True,
|
||||
},
|
||||
}
|
||||
|
||||
for repo, rules in repos.items():
|
||||
apply_branch_protection(repo, rules)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -80,6 +80,15 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Poka-yoke: write a cron heartbeat so check_cron_heartbeats.py can detect
|
||||
# if *this* watchdog stops running. Import lazily to stay zero-dep if the
|
||||
# nexus package is unavailable (e.g. very minimal test environments).
|
||||
try:
|
||||
from nexus.cron_heartbeat import write_cron_heartbeat as _write_cron_heartbeat
|
||||
_HAS_CRON_HEARTBEAT = True
|
||||
except ImportError:
|
||||
_HAS_CRON_HEARTBEAT = False
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||
@@ -488,6 +497,15 @@ def run_once(args: argparse.Namespace) -> bool:
|
||||
elif not args.dry_run:
|
||||
alert_on_failure(report, dry_run=args.dry_run)
|
||||
|
||||
# Poka-yoke: stamp our own heartbeat so the meta-checker can detect
|
||||
# if this watchdog cron job itself goes silent. Runs every 5 minutes
|
||||
# by convention (*/5 * * * *).
|
||||
if _HAS_CRON_HEARTBEAT:
|
||||
try:
|
||||
_write_cron_heartbeat("nexus_watchdog", interval_seconds=300)
|
||||
except Exception:
|
||||
pass # never crash the watchdog over its own heartbeat
|
||||
|
||||
return report.overall_healthy
|
||||
|
||||
|
||||
|
||||
247
bin/night_watch.py
Normal file
247
bin/night_watch.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Night Watch — Bezalel nightly report generator.
|
||||
|
||||
Runs once per night (typically at 03:00 local time via cron) and writes a
|
||||
markdown report to ``reports/bezalel/nightly/<YYYY-MM-DD>.md``.
|
||||
|
||||
The report always includes a **Heartbeat Panel** (acceptance criterion #3 of
|
||||
issue #1096) so silent cron failures are visible in the morning brief.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python bin/night_watch.py # write today's report
|
||||
python bin/night_watch.py --dry-run # print to stdout, don't write file
|
||||
python bin/night_watch.py --date 2026-04-08 # specific date
|
||||
|
||||
CRONTAB
|
||||
-------
|
||||
0 3 * * * cd /path/to/the-nexus && python bin/night_watch.py \\
|
||||
>> /var/log/bezalel/night-watch.log 2>&1
|
||||
|
||||
ZERO DEPENDENCIES
|
||||
-----------------
|
||||
Pure stdlib, plus ``check_cron_heartbeats`` from this repo (also stdlib).
|
||||
|
||||
Refs: #1096
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("bezalel.night_watch")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
REPORTS_DIR = PROJECT_ROOT / "reports" / "bezalel" / "nightly"
|
||||
|
||||
# ── Load check_cron_heartbeats without relying on sys.path hacks ──────
|
||||
|
||||
def _load_checker():
|
||||
"""Import bin/check_cron_heartbeats.py as a module."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_check_cron_heartbeats",
|
||||
PROJECT_ROOT / "bin" / "check_cron_heartbeats.py",
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ── System checks ─────────────────────────────────────────────────────
|
||||
|
||||
def _check_service(service_name: str) -> tuple[str, str]:
|
||||
"""Return (status, detail) for a systemd service."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", service_name],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
active = result.stdout.strip()
|
||||
if active == "active":
|
||||
return "OK", f"{service_name} is active"
|
||||
return "WARN", f"{service_name} is {active}"
|
||||
except FileNotFoundError:
|
||||
return "OK", f"{service_name} status unknown (systemctl not available)"
|
||||
except Exception as exc:
|
||||
return "WARN", f"systemctl error: {exc}"
|
||||
|
||||
|
||||
def _check_disk(threshold_pct: int = 90) -> tuple[str, str]:
|
||||
"""Return (status, detail) for disk usage on /."""
|
||||
try:
|
||||
usage = shutil.disk_usage("/")
|
||||
pct = int(usage.used / usage.total * 100)
|
||||
status = "OK" if pct < threshold_pct else "WARN"
|
||||
return status, f"disk usage {pct}%"
|
||||
except Exception as exc:
|
||||
return "WARN", f"disk check failed: {exc}"
|
||||
|
||||
|
||||
def _check_memory(threshold_pct: int = 90) -> tuple[str, str]:
|
||||
"""Return (status, detail) for memory usage."""
|
||||
try:
|
||||
meminfo = Path("/proc/meminfo").read_text()
|
||||
data = {}
|
||||
for line in meminfo.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
data[parts[0].rstrip(":")] = int(parts[1])
|
||||
total = data.get("MemTotal", 0)
|
||||
available = data.get("MemAvailable", 0)
|
||||
if total == 0:
|
||||
return "OK", "memory info unavailable"
|
||||
pct = int((total - available) / total * 100)
|
||||
status = "OK" if pct < threshold_pct else "WARN"
|
||||
return status, f"memory usage {pct}%"
|
||||
except FileNotFoundError:
|
||||
# Not Linux (e.g. macOS dev machine)
|
||||
return "OK", "memory check skipped (not Linux)"
|
||||
except Exception as exc:
|
||||
return "WARN", f"memory check failed: {exc}"
|
||||
|
||||
|
||||
def _check_gitea_reachability(gitea_url: str = "https://forge.alexanderwhitestone.com") -> tuple[str, str]:
|
||||
"""Return (status, detail) for Gitea HTTPS reachability."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
try:
|
||||
with urllib.request.urlopen(gitea_url, timeout=10) as resp:
|
||||
code = resp.status
|
||||
if code == 200:
|
||||
return "OK", f"Alpha SSH not configured from Beta, but Gitea HTTPS is responding ({code})"
|
||||
return "WARN", f"Gitea returned HTTP {code}"
|
||||
except Exception as exc:
|
||||
return "WARN", f"Gitea unreachable: {exc}"
|
||||
|
||||
|
||||
def _check_world_readable_secrets() -> tuple[str, str]:
|
||||
"""Return (status, detail) for world-readable sensitive files."""
|
||||
sensitive_patterns = ["*.key", "*.pem", "*.secret", ".env", "*.token"]
|
||||
found = []
|
||||
try:
|
||||
for pattern in sensitive_patterns:
|
||||
for path in PROJECT_ROOT.rglob(pattern):
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
if mode & 0o004: # world-readable
|
||||
found.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
except OSError:
|
||||
pass
|
||||
if found:
|
||||
return "WARN", f"world-readable sensitive files: {', '.join(found[:3])}"
|
||||
return "OK", "no sensitive recently-modified world-readable files found"
|
||||
except Exception as exc:
|
||||
return "WARN", f"security check failed: {exc}"
|
||||
|
||||
|
||||
# ── Report generation ─────────────────────────────────────────────────
|
||||
|
||||
def generate_report(date_str: str, checker_mod) -> str:
|
||||
"""Build the full nightly report markdown string."""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
ts = now_utc.strftime("%Y-%m-%d %02H:%M UTC")
|
||||
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
|
||||
service_status, service_detail = _check_service("hermes-bezalel")
|
||||
rows.append(("Service", service_status, service_detail))
|
||||
|
||||
disk_status, disk_detail = _check_disk()
|
||||
rows.append(("Disk", disk_status, disk_detail))
|
||||
|
||||
mem_status, mem_detail = _check_memory()
|
||||
rows.append(("Memory", mem_status, mem_detail))
|
||||
|
||||
gitea_status, gitea_detail = _check_gitea_reachability()
|
||||
rows.append(("Alpha VPS", gitea_status, gitea_detail))
|
||||
|
||||
sec_status, sec_detail = _check_world_readable_secrets()
|
||||
rows.append(("Security", sec_status, sec_detail))
|
||||
|
||||
overall = "OK" if all(r[1] == "OK" for r in rows) else "WARN"
|
||||
|
||||
lines = [
|
||||
f"# Bezalel Night Watch — {ts}",
|
||||
"",
|
||||
f"**Overall:** {overall}",
|
||||
"",
|
||||
"| Check | Status | Detail |",
|
||||
"|-------|--------|--------|",
|
||||
]
|
||||
for check, status, detail in rows:
|
||||
lines.append(f"| {check} | {status} | {detail} |")
|
||||
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# ── Heartbeat Panel (acceptance criterion #1096) ──────────────────
|
||||
try:
|
||||
hb_report = checker_mod.build_report()
|
||||
lines.append(hb_report.to_panel_markdown())
|
||||
except Exception as exc:
|
||||
lines += [
|
||||
"## Heartbeat Panel",
|
||||
"",
|
||||
f"*(heartbeat check failed: {exc})*",
|
||||
]
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"*Automated by Bezalel Night Watch*",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bezalel Night Watch — nightly report generator",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date", default=None,
|
||||
help="Report date as YYYY-MM-DD (default: today UTC)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Print report to stdout instead of writing to disk",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
date_str = args.date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
checker = _load_checker()
|
||||
report_text = generate_report(date_str, checker)
|
||||
|
||||
if args.dry_run:
|
||||
print(report_text)
|
||||
return
|
||||
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
report_path = REPORTS_DIR / f"{date_str}.md"
|
||||
report_path.write_text(report_text)
|
||||
logger.info("Night Watch report written to %s", report_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
43
bin/setup_gitea_protections.py
Normal file
43
bin/setup_gitea_protections.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, List
|
||||
|
||||
GITEA_API = os.getenv("GITEA_API_URL", "https://forge.alexanderwhitestone.com/api/v1")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN")
|
||||
REPOS = [
|
||||
"hermes-agent",
|
||||
"the-nexus",
|
||||
"timmy-home",
|
||||
"timmy-config",
|
||||
]
|
||||
|
||||
BRANCH_PROTECTION = {
|
||||
"required_pull_request_reviews": True,
|
||||
"required_status_checks": True,
|
||||
"required_signatures": False,
|
||||
"required_linear_history": False,
|
||||
"allow_force_push": False,
|
||||
"allow_deletions": False,
|
||||
"required_approvals": 1,
|
||||
"dismiss_stale_reviews": True,
|
||||
"restrictions": {
|
||||
"users": ["@perplexity"],
|
||||
"teams": []
|
||||
}
|
||||
}
|
||||
|
||||
def apply_protection(repo: str):
|
||||
url = f"{GITEA_API}/repos/Timmy_Foundation/{repo}/branches/main/protection"
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.post(url, json=BRANCH_PROTECTION, headers=headers)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ Protection applied to {repo}/main")
|
||||
else:
|
||||
print(f"❌ Failed to apply protection to {repo}/main: {response.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
for repo in REPOS:
|
||||
apply_protection(repo)
|
||||
168
docs/QUARANTINE_PROCESS.md
Normal file
168
docs/QUARANTINE_PROCESS.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Quarantine Process
|
||||
|
||||
**Poka-yoke principle:** a flaky or broken test must never silently rot in
|
||||
place. Quarantine is the correction step in the
|
||||
Prevention → Detection → Correction triad described in issue #1094.
|
||||
|
||||
---
|
||||
|
||||
## When to quarantine
|
||||
|
||||
Quarantine a test when **any** of the following are true:
|
||||
|
||||
| Signal | Source |
|
||||
|--------|--------|
|
||||
| `flake_detector.py` flags the test at < 95 % consistency | Automated |
|
||||
| The test fails intermittently in CI over two consecutive runs | Manual observation |
|
||||
| The test depends on infrastructure that is temporarily unavailable | Manual observation |
|
||||
| You are fixing a bug and need to defer a related test | Developer judgement |
|
||||
|
||||
Do **not** use quarantine as a way to ignore tests indefinitely. The
|
||||
quarantine directory is a **30-day time-box** — see the escalation rule below.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step workflow
|
||||
|
||||
### 1 File an issue
|
||||
|
||||
Open a Gitea issue with the title prefix `[FLAKY]` or `[BROKEN]`:
|
||||
|
||||
```
|
||||
[FLAKY] test_foo_bar non-deterministically fails with assertion error
|
||||
```
|
||||
|
||||
Note the issue number — you will need it in the next step.
|
||||
|
||||
### 2 Move the test file
|
||||
|
||||
Move (or copy) the test from `tests/` into `tests/quarantine/`.
|
||||
|
||||
```bash
|
||||
git mv tests/test_my_thing.py tests/quarantine/test_my_thing.py
|
||||
```
|
||||
|
||||
If only individual test functions are flaky, extract them into a new file in
|
||||
`tests/quarantine/` rather than moving the whole module.
|
||||
|
||||
### 3 Annotate the test
|
||||
|
||||
Add the `@pytest.mark.quarantine` marker with the issue reference:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.quarantine(reason="Flaky until #NNN is resolved")
|
||||
def test_my_thing():
|
||||
...
|
||||
```
|
||||
|
||||
This satisfies the poka-yoke skip-enforcement rule: the test is allowed to
|
||||
skip/be excluded because it is explicitly linked to a tracking issue.
|
||||
|
||||
### 4 Verify CI still passes
|
||||
|
||||
```bash
|
||||
pytest # default run — quarantine tests are excluded
|
||||
pytest --run-quarantine # optional: run quarantined tests explicitly
|
||||
```
|
||||
|
||||
The main CI run must be green before merging.
|
||||
|
||||
### 5 Add to `.test-history.json` exclusions (optional)
|
||||
|
||||
If the flake detector is tracking the test, add it to the `quarantine_list` in
|
||||
`.test-history.json` so it is excluded from the consistency report:
|
||||
|
||||
```json
|
||||
{
|
||||
"quarantine_list": [
|
||||
"tests/quarantine/test_my_thing.py::test_my_thing"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Escalation rule
|
||||
|
||||
If a quarantined test's tracking issue has had **no activity for 30 days**,
|
||||
the next developer to touch that file must:
|
||||
|
||||
1. Attempt to fix and un-quarantine the test, **or**
|
||||
2. Delete the test and close the issue with a comment explaining why, **or**
|
||||
3. Leave a comment on the issue explaining the blocker and reset the 30-day
|
||||
clock explicitly.
|
||||
|
||||
**A test may not stay in quarantine indefinitely without active attention.**
|
||||
|
||||
---
|
||||
|
||||
## Un-quarantining a test
|
||||
|
||||
When the underlying issue is resolved:
|
||||
|
||||
1. Remove `@pytest.mark.quarantine` from the test.
|
||||
2. Move the file back from `tests/quarantine/` to `tests/`.
|
||||
3. Run the full suite to confirm it passes consistently (at least 3 local runs).
|
||||
4. Close the tracking issue.
|
||||
5. Remove any entries from `.test-history.json`'s `quarantine_list`.
|
||||
|
||||
---
|
||||
|
||||
## Flake detector integration
|
||||
|
||||
The flake detector (`scripts/flake_detector.py`) is run after every CI test
|
||||
execution. It reads `.test-report.json` (produced by `pytest --json-report`)
|
||||
and updates `.test-history.json`.
|
||||
|
||||
**CI integration example (shell script or CI step):**
|
||||
|
||||
```bash
|
||||
pytest --json-report --json-report-file=.test-report.json
|
||||
python scripts/flake_detector.py
|
||||
```
|
||||
|
||||
If the flake detector exits non-zero, the CI step fails and the output lists
|
||||
the offending tests with their consistency percentages.
|
||||
|
||||
**Local usage:**
|
||||
|
||||
```bash
|
||||
# After running tests with JSON report:
|
||||
python scripts/flake_detector.py
|
||||
|
||||
# Just view current statistics without ingesting a new report:
|
||||
python scripts/flake_detector.py --no-update
|
||||
|
||||
# Lower threshold for local dev:
|
||||
python scripts/flake_detector.py --threshold 0.90
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
```
|
||||
Test fails intermittently
|
||||
│
|
||||
▼
|
||||
File [FLAKY] issue
|
||||
│
|
||||
▼
|
||||
git mv test → tests/quarantine/
|
||||
│
|
||||
▼
|
||||
Add @pytest.mark.quarantine(reason="#NNN")
|
||||
│
|
||||
▼
|
||||
Main CI green ✓
|
||||
│
|
||||
▼
|
||||
Fix the root cause (within 30 days)
|
||||
│
|
||||
▼
|
||||
git mv back → tests/
|
||||
Remove quarantine marker
|
||||
Close issue ✓
|
||||
```
|
||||
33
docs/branch_protection.md
Normal file
33
docs/branch_protection.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Branch Protection & Mandatory Review Policy
|
||||
|
||||
## Overview
|
||||
|
||||
This policy ensures that all changes to the `main` branch are reviewed and tested before being merged. It applies to all repositories in the organization.
|
||||
|
||||
## Enforced Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| ✅ Require Pull Request | Direct pushes to `main` are blocked |
|
||||
| ✅ Require 1 Approval | At least one reviewer must approve |
|
||||
| ✅ Dismiss Stale Approvals | Approvals are dismissed on new commits |
|
||||
| ✅ Require CI to Pass | Merges are blocked if CI fails |
|
||||
| ✅ Block Force Push | Prevents rewriting of `main` history |
|
||||
| ✅ Block Branch Deletion | Prevents accidental deletion of `main` |
|
||||
|
||||
## Default Reviewers
|
||||
|
||||
- `@perplexity` is the default reviewer for all repositories
|
||||
- `@Timmy` is a required reviewer for `hermes-agent`
|
||||
|
||||
## Compliance
|
||||
|
||||
This policy is enforced via automation using the `bin/enforce_branch_protection.py` script, which applies these rules to all repositories.
|
||||
|
||||
## Exceptions
|
||||
|
||||
No exceptions are currently defined. All repositories must comply with this policy.
|
||||
|
||||
## Audit
|
||||
|
||||
This policy is audited quarterly to ensure compliance and effectiveness.
|
||||
26
docs/branch_protection_policy.md
Normal file
26
docs/branch_protection_policy.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Enforcement Rules
|
||||
|
||||
All repositories must:
|
||||
- Require PR for main branch merges
|
||||
- Require 1 approval
|
||||
- Dismiss stale approvals
|
||||
- Block force pushes
|
||||
- Block branch deletion
|
||||
|
||||
## Reviewer Assignments
|
||||
- All repos: @perplexity (QA gate)
|
||||
- hermes-agent: @Timmy (owner gate)
|
||||
|
||||
## CI Requirements
|
||||
- hermes-agent: Full CI required
|
||||
- the-nexus: CI pending (issue #915)
|
||||
- timmy-config: Limited ci
|
||||
|
||||
## Compliance
|
||||
This policy blocks:
|
||||
- Direct pushes to main
|
||||
- Unreviewed merges
|
||||
- Merges with failing ci
|
||||
- History rewriting
|
||||
22
docs/mempalace/bezalel_example.yaml
Normal file
22
docs/mempalace/bezalel_example.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Example wizard mempalace.yaml — Bezalel
|
||||
# Used by CI to validate that validate_rooms.py passes against a compliant config.
|
||||
# Refs: #1082, #1075
|
||||
|
||||
wizard: bezalel
|
||||
version: "1"
|
||||
|
||||
rooms:
|
||||
- key: forge
|
||||
label: Forge
|
||||
- key: hermes
|
||||
label: Hermes
|
||||
- key: nexus
|
||||
label: Nexus
|
||||
- key: issues
|
||||
label: Issues
|
||||
- key: experiments
|
||||
label: Experiments
|
||||
- key: evennia
|
||||
label: Evennia
|
||||
- key: workspace
|
||||
label: Workspace
|
||||
183
docs/mempalace/rooms.yaml
Normal file
183
docs/mempalace/rooms.yaml
Normal file
@@ -0,0 +1,183 @@
|
||||
# MemPalace Fleet Room Taxonomy Standard
|
||||
# =======================================
|
||||
# Version: 1.0
|
||||
# Milestone: MemPalace × Evennia — Fleet Memory (#1075)
|
||||
# Issue: #1082 [Infra] Palace taxonomy standard
|
||||
#
|
||||
# Every wizard's palace MUST contain the five core rooms listed below.
|
||||
# Domain rooms are optional and wizard-specific.
|
||||
#
|
||||
# Format:
|
||||
# rooms:
|
||||
# <room_key>:
|
||||
# required: true|false
|
||||
# description: one-liner purpose
|
||||
# example_topics: [list of things that belong here]
|
||||
# tunnel: true if a cross-wizard tunnel should exist for this room
|
||||
|
||||
rooms:
|
||||
|
||||
# ── Core rooms (required in every wing) ────────────────────────────────────
|
||||
|
||||
forge:
|
||||
required: true
|
||||
description: "CI, builds, deployment, infra operations"
|
||||
example_topics:
|
||||
- "github actions failures"
|
||||
- "docker build logs"
|
||||
- "server deployment steps"
|
||||
- "cron job setup"
|
||||
tunnel: true
|
||||
|
||||
hermes:
|
||||
required: true
|
||||
description: "Agent platform, gateway, CLI tooling, harness internals"
|
||||
example_topics:
|
||||
- "hermes session logs"
|
||||
- "agent wake cycle"
|
||||
- "MCP tool calls"
|
||||
- "gateway configuration"
|
||||
tunnel: true
|
||||
|
||||
nexus:
|
||||
required: true
|
||||
description: "Reports, docs, knowledge transfer, SITREPs"
|
||||
example_topics:
|
||||
- "nightly watch report"
|
||||
- "architecture docs"
|
||||
- "handoff notes"
|
||||
- "decision records"
|
||||
tunnel: true
|
||||
|
||||
issues:
|
||||
required: true
|
||||
description: "Gitea tickets, backlog items, bug reports, PR reviews"
|
||||
example_topics:
|
||||
- "issue triage"
|
||||
- "PR feedback"
|
||||
- "bug root cause"
|
||||
- "milestone planning"
|
||||
tunnel: true
|
||||
|
||||
experiments:
|
||||
required: true
|
||||
description: "Prototypes, spikes, research, benchmarks"
|
||||
example_topics:
|
||||
- "spike results"
|
||||
- "benchmark numbers"
|
||||
- "proof of concept"
|
||||
- "chromadb evaluation"
|
||||
tunnel: true
|
||||
|
||||
# ── Write rooms (created on demand by CmdRecord/CmdNote/CmdEvent) ──────────
|
||||
|
||||
hall_facts:
|
||||
required: false
|
||||
description: "Decisions and facts recorded via 'record' command"
|
||||
example_topics:
|
||||
- "architectural decisions"
|
||||
- "policy choices"
|
||||
- "approved approaches"
|
||||
tunnel: false
|
||||
|
||||
hall_discoveries:
|
||||
required: false
|
||||
description: "Breakthroughs and key findings recorded via 'note' command"
|
||||
example_topics:
|
||||
- "performance breakthroughs"
|
||||
- "algorithmic insights"
|
||||
- "unexpected results"
|
||||
tunnel: false
|
||||
|
||||
hall_events:
|
||||
required: false
|
||||
description: "Significant events logged via 'event' command"
|
||||
example_topics:
|
||||
- "production deployments"
|
||||
- "milestones reached"
|
||||
- "incidents resolved"
|
||||
tunnel: false
|
||||
|
||||
# ── Optional domain rooms (wizard-specific) ────────────────────────────────
|
||||
|
||||
evennia:
|
||||
required: false
|
||||
description: "Evennia MUD world: rooms, commands, NPCs, world design"
|
||||
example_topics:
|
||||
- "command implementation"
|
||||
- "typeclass design"
|
||||
- "world building notes"
|
||||
wizard: ["bezalel"]
|
||||
tunnel: false
|
||||
|
||||
game_portals:
|
||||
required: false
|
||||
description: "Portal/gameplay work: satflow, economy, portal registry"
|
||||
example_topics:
|
||||
- "portal specs"
|
||||
- "satflow visualization"
|
||||
- "economy rules"
|
||||
wizard: ["bezalel", "timmy"]
|
||||
tunnel: false
|
||||
|
||||
workspace:
|
||||
required: false
|
||||
description: "General wizard workspace notes that don't fit elsewhere"
|
||||
example_topics:
|
||||
- "daily notes"
|
||||
- "scratch work"
|
||||
- "reference lookups"
|
||||
tunnel: false
|
||||
|
||||
general:
|
||||
required: false
|
||||
description: "Fallback room for unclassified memories"
|
||||
example_topics:
|
||||
- "uncategorized notes"
|
||||
tunnel: false
|
||||
|
||||
|
||||
# ── Tunnel policy ─────────────────────────────────────────────────────────────
|
||||
#
|
||||
# A tunnel is a cross-wing link that lets any wizard recall memories
|
||||
# from an equivalent room in another wing.
|
||||
#
|
||||
# Rules:
|
||||
# 1. Only CLOSETS (summaries) are synced through tunnels — never raw drawers.
|
||||
# 2. Required rooms marked tunnel:true MUST have tunnels on Alpha.
|
||||
# 3. Optional rooms are never tunnelled unless explicitly opted in.
|
||||
# 4. Raw drawers (source_file metadata) never leave the local VPS.
|
||||
|
||||
tunnels:
|
||||
policy: closets_only
|
||||
sync_schedule: "04:00 UTC nightly"
|
||||
destination: "/var/lib/mempalace/fleet"
|
||||
rooms_synced:
|
||||
- forge
|
||||
- hermes
|
||||
- nexus
|
||||
- issues
|
||||
- experiments
|
||||
|
||||
|
||||
# ── Privacy rules ─────────────────────────────────────────────────────────────
|
||||
#
|
||||
# See issue #1083 for the full privacy boundary design.
|
||||
#
|
||||
# Summary:
|
||||
# - hall_facts, hall_discoveries, hall_events: LOCAL ONLY (never synced)
|
||||
# - workspace, general: LOCAL ONLY
|
||||
# - Domain rooms (evennia, game_portals): LOCAL ONLY unless tunnel:true
|
||||
# - source_file paths MUST be stripped before sync
|
||||
|
||||
privacy:
|
||||
local_only_rooms:
|
||||
- hall_facts
|
||||
- hall_discoveries
|
||||
- hall_events
|
||||
- workspace
|
||||
- general
|
||||
strip_on_sync:
|
||||
- source_file
|
||||
retention_days: 90
|
||||
archive_flag: "archive: true"
|
||||
145
docs/mempalace_taxonomy.yaml
Normal file
145
docs/mempalace_taxonomy.yaml
Normal file
@@ -0,0 +1,145 @@
|
||||
# Fleet-wide MemPalace Room Taxonomy Standard
|
||||
# Repository: Timmy_Foundation/the-nexus
|
||||
# Version: 1.0
|
||||
# Date: 2026-04-07
|
||||
#
|
||||
# Purpose: Guarantee that tunnels work across wizard wings and that
|
||||
# fleet-wide search returns predictable, structured results.
|
||||
#
|
||||
# Usage: Every wizard's mempalace.yaml MUST include the 5 CORE rooms.
|
||||
# OPTIONAL rooms may be added per wizard domain.
|
||||
|
||||
---
|
||||
standard_version: "1.0"
|
||||
required_rooms:
|
||||
forge:
|
||||
description: CI pipelines, builds, syntax guards, health checks, deployments
|
||||
keywords:
|
||||
- ci
|
||||
- build
|
||||
- test
|
||||
- syntax
|
||||
- guard
|
||||
- health
|
||||
- check
|
||||
- nightly
|
||||
- watch
|
||||
- forge
|
||||
- deploy
|
||||
- pipeline
|
||||
- runner
|
||||
- actions
|
||||
|
||||
hermes:
|
||||
description: Hermes agent source code, gateway, CLI, tool platform
|
||||
keywords:
|
||||
- hermes
|
||||
- agent
|
||||
- gateway
|
||||
- cli
|
||||
- tool
|
||||
- platform
|
||||
- provider
|
||||
- model
|
||||
- fallback
|
||||
- mcp
|
||||
|
||||
nexus:
|
||||
description: Reports, documentation, knowledge-transfer artifacts, SITREPs
|
||||
keywords:
|
||||
- report
|
||||
- doc
|
||||
- nexus
|
||||
- kt
|
||||
- knowledge
|
||||
- transfer
|
||||
- sitrep
|
||||
- wiki
|
||||
- readme
|
||||
|
||||
issues:
|
||||
description: Gitea issues, pull requests, backlog tracking, tickets
|
||||
keywords:
|
||||
- issue
|
||||
- pr
|
||||
- pull
|
||||
- request
|
||||
- backlog
|
||||
- ticket
|
||||
- gitea
|
||||
- milestone
|
||||
- bug
|
||||
- fix
|
||||
|
||||
experiments:
|
||||
description: Active prototypes, spikes, scratch work, one-off scripts
|
||||
keywords:
|
||||
- workspace
|
||||
- prototype
|
||||
- experiment
|
||||
- scratch
|
||||
- draft
|
||||
- wip
|
||||
- spike
|
||||
- poc
|
||||
- sandbox
|
||||
|
||||
optional_rooms:
|
||||
evennia:
|
||||
description: Evennia MUD engine and world-building code
|
||||
keywords:
|
||||
- evennia
|
||||
- mud
|
||||
- world
|
||||
- room
|
||||
- object
|
||||
- command
|
||||
- typeclass
|
||||
|
||||
game-portals:
|
||||
description: Game portal integrations, 3D world bridges, player state
|
||||
keywords:
|
||||
- portal
|
||||
- game
|
||||
- 3d
|
||||
- world
|
||||
- player
|
||||
- session
|
||||
|
||||
lazarus-pit:
|
||||
description: Wizard recovery, resurrection, mission cell isolation
|
||||
keywords:
|
||||
- lazarus
|
||||
- pit
|
||||
- recovery
|
||||
- rescue
|
||||
- cell
|
||||
- isolation
|
||||
- reboot
|
||||
|
||||
home:
|
||||
description: Personal scripts, configs, notebooks, local utilities
|
||||
keywords:
|
||||
- home
|
||||
- config
|
||||
- notebook
|
||||
- script
|
||||
- utility
|
||||
- local
|
||||
- personal
|
||||
|
||||
halls:
|
||||
- hall_facts
|
||||
- hall_events
|
||||
- hall_discoveries
|
||||
- hall_preferences
|
||||
- hall_advice
|
||||
|
||||
tunnel_policy:
|
||||
auto_create: true
|
||||
match_on: room_name
|
||||
minimum_shared_rooms_for_tunnel: 2
|
||||
|
||||
validation:
|
||||
script: scripts/validate_mempalace_taxonomy.py
|
||||
ci_check: true
|
||||
42
docs/pr-reviewer-policy.md
Normal file
42
docs/pr-reviewer-policy.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# PR Reviewer Assignment Policy
|
||||
|
||||
**Effective: 2026-04-07** — Established after org-wide PR hygiene audit (issue #916).
|
||||
|
||||
## Rule: Every PR must have at least one reviewer assigned before merge.
|
||||
|
||||
No exceptions. Unreviewed PRs will not be merged.
|
||||
|
||||
## Who to assign
|
||||
|
||||
| PR type | Default reviewer |
|
||||
|---|---|
|
||||
| Security / auth changes | @perplexity |
|
||||
| Infrastructure / fleet | @perplexity |
|
||||
| Sovereignty / local inference | @perplexity |
|
||||
| Documentation | any team member |
|
||||
| Agent-generated PRs | @perplexity |
|
||||
|
||||
When in doubt, assign @perplexity.
|
||||
|
||||
## Why this policy exists
|
||||
|
||||
Audit on 2026-04-07 found 5 open PRs across the org — zero had a reviewer assigned.
|
||||
Two PRs containing critical security and sovereignty work (hermes-agent #131, #170) drifted
|
||||
400+ commits from `main` and became unmergeable because nobody reviewed them while main advanced.
|
||||
|
||||
The cost: weeks of rebase work to rescue two commits of actual changes.
|
||||
|
||||
## PR hygiene rules
|
||||
|
||||
1. **Assign a reviewer on open.** Don't open a PR without a reviewer.
|
||||
2. **Rebase within 2 weeks.** If a PR sits for 2 weeks, rebase it or close it.
|
||||
3. **Close zombie PRs.** A PR with 0 commits ahead of base should be closed immediately.
|
||||
4. **Cherry-pick, don't rebase 400 commits.** When a branch drifts far, extract the actual
|
||||
changes onto a fresh branch rather than rebasing the entire history.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Agent-opened PRs (Timmy, Claude, etc.) must include `reviewers` in the PR creation payload.
|
||||
The forge API accepts `"reviewers": ["perplexity"]` in the PR body.
|
||||
|
||||
See: issue #916 for the audit that established this policy.
|
||||
49
docus/branch-protection.md
Normal file
49
docus/branch-protection.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Branch Protection Policy
|
||||
|
||||
## Enforcement Rules
|
||||
|
||||
All repositories must have the following branch protection rules enabled on the `main` branch:
|
||||
|
||||
| Rule | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Require PR for merge | ✅ Enabled | No direct pushes to main |
|
||||
| Required approvals | ✅ 1 approval | At least one reviewer must approve |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Where CI exists | No merging with failing CI |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental main deletion |
|
||||
|
||||
## Reviewer Assignments
|
||||
|
||||
- `@perplexity` - Default reviewer for all repositories
|
||||
- `@Timmy` - Required reviewer for `hermes-agent`
|
||||
|
||||
- Repo-specific owners for specialized areas (e.g., `@Rockachopa` for infrastructure)
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- [x] `hermes-agent`: All rules enabled
|
||||
- [x] `the-nexus`: All rules enabled (CI pending)
|
||||
- [x] `timmy-home`: PR + 1 approval
|
||||
- [x] `timmy-config`: PR + 1 approval
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all main branches
|
||||
- [x] `@perplexity` set as default reviewer
|
||||
- [x] This documentation added to all repositories
|
||||
|
||||
## Blocked Issues
|
||||
|
||||
- [ ] #916 - CI implementation for `the-nexus`
|
||||
- [ ] #917 - Reviewer assignment automation
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Gitea branch protection settings must be configured via the UI:
|
||||
- Settings > Branches > Branch Protection
|
||||
- Enable all rules listed above
|
||||
|
||||
2. `CODEOWNERS` file must be committed to the root of each repository
|
||||
|
||||
3. CI status should be verified before merging
|
||||
12
electron-main.js
Normal file
12
electron-main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { exec } = require('child_process')
|
||||
|
||||
// MemPalace integration
|
||||
ipcMain.handle('exec-python', (event, command) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
if (error) return reject(error)
|
||||
resolve({ stdout, stderr })
|
||||
})
|
||||
})
|
||||
})
|
||||
75
gitea-branch-protection.js
Normal file
75
gitea-branch-protection.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const GiteaApiUrl = 'https://forge.alexanderwhitestone.com/api/v1';
|
||||
const token = process.env.GITEA_TOKEN; // Should be stored securely in environment variables
|
||||
const repos = ['hermes-agent', 'the-nexus', 'timmy-home', 'timmy-config'];
|
||||
|
||||
const branchProtectionSettings = {
|
||||
enablePush: false,
|
||||
enableMerge: true,
|
||||
requiredApprovals: 1,
|
||||
dismissStaleApprovals: true,
|
||||
requiredStatusChecks: true,
|
||||
blockForcePush: true,
|
||||
blockDelete: true
|
||||
// Special handling for the-nexus (CI disabled)
|
||||
};
|
||||
|
||||
async function applyBranchProtection(repo) {
|
||||
try {
|
||||
const response = await fetch(`${giteaApiUrl}/repos/Timmy_Foundation/${repo}/branches/main/protection`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...branchProtectionSettings,
|
||||
// Special handling for the-nexus (CI disabled)
|
||||
requiredStatusChecks: repo === 'the-nexus' ? false : true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to apply branch protection to ${repo}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
console.log(`✅ Branch protection applied to ${repo}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error applying branch protection to ${repo}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBranchProtection(repo) {
|
||||
try {
|
||||
const response = await fetch(`${giteaApiUrl}/repos/Timmy_Foundation/${repo}/branches/main/protection`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...branchProtectionSettings,
|
||||
requiredApprovals: repo === 'hermes-agent' ? 2 : 1,
|
||||
requiredStatusChecks: repo === 'the-nexus' ? false : true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to apply branch protection to ${repo}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
console.log(`✅ Branch protection applied to ${repo}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error applying branch protection to ${repo}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupAllBranchProtections() {
|
||||
console.log('🚀 Applying branch protections to all repositories...');
|
||||
for (const repo of repos) {
|
||||
await applyBranchProtection(repo);
|
||||
}
|
||||
console.log('✅ All branch protections applied successfully');
|
||||
}
|
||||
|
||||
// Run the setup
|
||||
setupAllBranchProtections();
|
||||
44
gitea-branch-protection.sh
Normal file
44
gitea-branch-protection.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Apply branch protections to all repositories
|
||||
# Requires GITEA_TOKEN env var
|
||||
|
||||
REPOS=("hermes-agent" "the-nexus" "timmy-home" "timmy-config")
|
||||
|
||||
for repo in "${REPOS[@]}"
|
||||
do
|
||||
curl -X POST "https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/$repo/branches/main/protection" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"required_reviews": 1,
|
||||
"dismiss_stale_reviews": true,
|
||||
"block_force_push": true,
|
||||
"block_deletions": true
|
||||
}'
|
||||
done
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea API credentials
|
||||
GITEA_TOKEN="your-personal-access-token"
|
||||
GITEA_API="https://forge.alexanderwhitestone.com/api/v1"
|
||||
|
||||
# Repos to protect
|
||||
REPOS=("hermes-agent" "the-nexus" "timmy-home" "timmy-config")
|
||||
|
||||
for REPO in "${REPO[@]}"; do
|
||||
echo "Configuring branch protection for $REPO..."
|
||||
|
||||
curl -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "main",
|
||||
"require_pull_request": true,
|
||||
"required_approvals": 1,
|
||||
"dismiss_stale_approvals": true,
|
||||
"required_status_checks": '"$(test "$REPO" = "hermes-agent" && echo "true" || echo "false")"',
|
||||
"block_force_push": true,
|
||||
"block_delete": true
|
||||
}' \
|
||||
"$GITEA_API/repos/Timmy_Foundation/$REPO/branch_protection"
|
||||
done
|
||||
36
gitea_api/branch_protection.py
Normal file
36
gitea_api/branch_protection.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
GITEA_API = os.getenv('Gitea_api_url', 'https://forge.alexanderwhitestone.com/api/v1')
|
||||
Gitea_token = os.getenv('GITEA_TOKEN')
|
||||
|
||||
headers = {
|
||||
'Authorization': f'token {gitea_token}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
def apply_branch_protection(owner, repo, branch='main'):
|
||||
payload = {
|
||||
"protected": True,
|
||||
"merge_method": "merge",
|
||||
"push": False,
|
||||
"pull_request": True,
|
||||
"required_signoff": False,
|
||||
"required_reviews": 1,
|
||||
"required_status_checks": True,
|
||||
"restrict_owners": True,
|
||||
"delete": False,
|
||||
"force_push": False
|
||||
}
|
||||
|
||||
url = f"{GITEA_API}/repos/{owner}/{repo}/branches/{branch}/protection"
|
||||
r = requests.post(url, json=payload, headers=headers)
|
||||
return r.status_code, r.json()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Apply to all repos
|
||||
for repo in ['hermes-agent', 'the-nexus', 'timmy-home', 'timmy-config']:
|
||||
print(f"Configuring {repo}...")
|
||||
status, resp = apply_branch_protection('Timmy_Foundation', repo)
|
||||
print(f"Status: {status} {resp}")
|
||||
10
hermes-agent/.github/CODEOWNERS
vendored
Normal file
10
hermes-agent/.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# CODEOWNERS for hermes-agent
|
||||
* @perplexity
|
||||
@Timmy
|
||||
# CODEOWNERS for the-nexus
|
||||
|
||||
* @perplexity
|
||||
@Rockachopa
|
||||
# CODEOWNERS for timmy-config
|
||||
|
||||
* @perplexity
|
||||
3
hermes-agent/CODEOWNERS
Normal file
3
hermes-agent/CODEOWNERS
Normal file
@@ -0,0 +1,3 @@
|
||||
@Timmy
|
||||
* @perplexity
|
||||
**/src @Timmy
|
||||
18
hermes-agent/CONTRIBUTING.md
Normal file
18
hermes-agent/CONTRIBUTING.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Contribution Policy for hermes-agent
|
||||
|
||||
## Branch Protection Rules
|
||||
All changes to the `main` branch require:
|
||||
- Pull Request with at least 1 approval
|
||||
- CI checks passing
|
||||
- No direct commits or force pushes
|
||||
- No deletion of the main branch
|
||||
|
||||
## Review Requirements
|
||||
- All PRs must be reviewed by @perplexity
|
||||
- Additional review required from @Timmy
|
||||
|
||||
## Stale PR Policy
|
||||
- Stale approvals are dismissed on new commits
|
||||
- Abandoned PRs will be closed after 7 days of inactivity
|
||||
|
||||
For urgent fixes, create a hotfix branch and follow the same review process.
|
||||
140
index.html
140
index.html
@@ -246,6 +246,135 @@
|
||||
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
|
||||
Created with Perplexity Computer
|
||||
</a>
|
||||
<a href="POLICY.md" target="_blank" rel="noopener noreferrer">
|
||||
View Contribution Policy
|
||||
</a>
|
||||
<div class="branch-policy" style="margin-top: 10px; font-size: 12px; color: #aaa;">
|
||||
<strong>BRANCH PROTECTION POLICY</strong><br>
|
||||
<ul style="margin:0; padding-left:15px;">
|
||||
<li>• Require PR for merge ✅</li>
|
||||
<li>• Require 1 approval ✅</li>
|
||||
<li>• Dismiss stale approvals ✅</li>
|
||||
<li>• Require CI ✅ (where available)</li>
|
||||
<li>• Block force push ✅</li>
|
||||
<li>• Block branch deletion ✅</li>
|
||||
</ul>
|
||||
<div style="margin-top: 8px;">
|
||||
<strong>DEFAULT REVIEWERS</strong><br>
|
||||
<span style="color:#4af0c0;">@perplexity</span> (QA gate on all repos) |
|
||||
<span style="color:#7b5cff;">@Timmy</span> (owner gate on hermes-agent)
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>IMPLEMENTATION STATUS</strong><br>
|
||||
<ul style="margin:0; padding-left:15px;">
|
||||
<li>• hermes-agent: Require PR + 1 approval + CI ✅</li>
|
||||
<li>• the-nexus: Require PR + 1 approval ⚠️ (CI disabled)</li>
|
||||
<li>• timmy-home: Require PR + 1 approval ✅</li>
|
||||
<li>• timmy-config: Require PR + 1 approval ✅</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="branch-policy" style="margin-top: 10px; font-size: 12px; color: #aaa;">
|
||||
<strong>BRANCH PROTECTION POLICY</strong><br>
|
||||
<ul style="margin:0; padding-left:15px;">
|
||||
<li>• Require PR for merge ✅</li>
|
||||
<li>• Require 1 approval ✅</li>
|
||||
<li>• Dismiss stale approvals ✅</li>
|
||||
<li>• Require CI ✅ (where available)</li>
|
||||
<li>• Block force push ✅</li>
|
||||
<li>• Block branch deletion ✅</li>
|
||||
<li>• Weekly audit for unreviewed merges ✅</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="mem-palace-container" class="mem-palace-ui">
|
||||
<div class="mem-palace-header">
|
||||
<span id="mem-palace-status">MEMPALACE</span>
|
||||
<button onclick="mineMemPalaceContent()" class="mem-palace-btn">Mine Chat</button>
|
||||
</div>
|
||||
<div class="mem-palace-stats">
|
||||
<div>Compression: <span id="compression-ratio">--</span>x</div>
|
||||
<div>Docs mined: <span id="docs-mined">0</span></div>
|
||||
<div>AAAK size: <span id="aaak-size">0B</span></div>
|
||||
</div>
|
||||
<div class="mem-palace-logs" id="mem-palace-logs"></div>
|
||||
</div>
|
||||
<div class="default-reviewers" style="margin-top: 8px; font-size: 12px; color: #aaa;">
|
||||
<strong>DEFAULT REVIEWERS</strong><br>
|
||||
<ul style="margin:0; padding-left:15px;">
|
||||
<li>• <span style="color:#4af0c0;">@perplexity</span> (QA gate on all repos)</li>
|
||||
<li>• <span style="color:#7b5cff;">@Timmy</span> (owner gate on hermes-agent)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="implementation-status" style="margin-top: 10px; font-size: 12px; color: #aaa;">
|
||||
<strong>IMPLEMENTATION STATUS</strong><br>
|
||||
<div style="margin-top: 5px; display: flex; flex-direction: column; gap: 2px;">
|
||||
<div>• <span style="color:#4af0c0;">hermes-agent</span>: Require PR + 1 approval + CI ✅</div>
|
||||
<div>• <span style="color:#7b5cff;">the-nexus</span>: Require PR + 1 approval ⚠️ (CI disabled)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mem-palace-status" style="position:fixed; right:24px; top:64px; background:rgba(74,240,192,0.1); color:#4af0c0; padding:6px 12px; border-radius:4px; font-family:'Orbitron', sans-serif; font-size:10px; letter-spacing:0.1em;">
|
||||
MEMPALACE INIT
|
||||
</div>
|
||||
<div>• <span style="color:#ffd700;">timmy-home</span>: Require PR + 1 approval ✅</div>
|
||||
<div>• <span style="color:#ab8d00;">timmy-config</span>: Require PR + 1 approval ✅</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mem-palace-container" class="mem-palace-ui">
|
||||
<div class="mem-palace-header">MemPalace <span id="mem-palace-status">Initializing...</span></div>
|
||||
<div class="mem-palace-stats">
|
||||
<div>Compression: <span id="compression-ratio">--</span>x</div>
|
||||
<div>Docs mined: <span id="docs-mined">0</span></div>
|
||||
<div>AAAK size: <span id="aaak-size">0B</span></div>
|
||||
</div>
|
||||
<div class="mem-palace-actions">
|
||||
<button id="mine-now-btn" class="mem-palace-btn" onclick="mineChatToMemPalace()">Mine Chat</button>
|
||||
<button class="mem-palace-btn" onclick="searchMemPalace()">Search</button>
|
||||
</div>
|
||||
<div id="mem-palace-logs" class="mem-palace-logs"></div>
|
||||
</div>
|
||||
<div id="mem-palace-controls" style="position:fixed; right:24px; top:54px; background:rgba(74,240,192,0.05); padding:4px 8px; font-family:'JetBrains Mono',monospace; font-size:11px; border-left:2px solid #4af0c0;">
|
||||
<button onclick="mineMemPalace()">Mine Chat</button>
|
||||
<button onclick="searchMemPalace()">Search</button>
|
||||
</div>
|
||||
<div id="mempalace-results" style="position:fixed; right:24px; top:84px; max-height:200px; overflow-y:auto; background:rgba(0,0,0,0.3); padding:8px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#e0f0ff; border-left:2px solid #4af0c0;"></div>
|
||||
<div id="mem-palace-controls" style="position:fixed; right:24px; top:54px; background:rgba(74,240,192,0.05); padding:4px 8px; font-family:'JetBrains Mono',monospace; font-size:10px; border-left:2px solid #4af0c0;">
|
||||
<button class="mem-palace-mining-btn" onclick="mineChatToMemPalace()">Mine Chat</button>
|
||||
<button onclick="searchMemPalace()">Search</button>
|
||||
</div>
|
||||
<div id="mempalace-results" style="position:fixed; right:24px; top:84px; max-height:200px; overflow-y:auto; background:rgba(0,0,0,0.3); padding:8px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#e0f0ff; border-left:2px solid #4af0c0;"></div>
|
||||
>>>>>>> replace
|
||||
```
|
||||
|
||||
index.html
|
||||
```html
|
||||
<<<<<<< search
|
||||
<div class="branch-policy" style="margin-top: 10px; font-size: 12px; color: #aaa;">
|
||||
<strong>BRANCH PROTECTION POLICY</strong><br>
|
||||
<ul style="margin:0; padding-left:15px;">
|
||||
<li>• Require PR for merge ✅</li>
|
||||
<li>• Require 1 approval ✅</li>
|
||||
<li>• Dismiss stale approvals ✅</li>
|
||||
<li>• Require CI ✅ (where available)</li>
|
||||
<li>• Block force push ✅</li>
|
||||
<li>• Block branch deletion ✅</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="default-reviewers" style="margin-top: 8px;">
|
||||
<strong>DEFAULT REVIEWERS</strong><br>
|
||||
<ul style="margin:0; padding-left:15px;">
|
||||
<li>• <span style="color:#4af0c0;">@perplexity</span> (QA gate on all repos)</li>
|
||||
<li>• <span style="color:#7b5cff;">@Timmy</span> (owner gate on hermes-agent)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="implementation-status" style="margin-top: 10px;">
|
||||
<strong>IMPLEMENTATION STATUS</strong><br>
|
||||
<div style="margin-top: 5px; display: flex; flex-direction: column; gap: 2px;">
|
||||
<div>• <span style="color:#4af0c0;">hermes-agent</span>: Require PR + 1 approval + CI ✅</div>
|
||||
<div>• <span style="color:#7b5cff;">the-nexus</span>: Require PR + 1 approval ⚠<> (CI disabled)</div>
|
||||
<div>• <span style="color:#ffd700;">timmy-home</span>: Require PR + 1 approval ✅</div>
|
||||
<div>• <span style="color:#ab8d00;">timmy-config</span>: Require PR + 1 approval ✅</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
@@ -281,6 +410,17 @@
|
||||
if (!sha) return;
|
||||
if (knownSha === null) { knownSha = sha; return; }
|
||||
if (sha !== knownSha) {
|
||||
// Check branch protection rules
|
||||
const branchRules = await fetch(`${GITEA}/repos/${REPO}/branches/${BRANCH}/protection`);
|
||||
if (!branchRules.ok) {
|
||||
console.error('Branch protection rules not enforced');
|
||||
return;
|
||||
}
|
||||
const rules = await branchRules.json();
|
||||
if (!rules.require_pr && !rules.require_approvals) {
|
||||
console.error('Branch protection rules not met');
|
||||
return;
|
||||
}
|
||||
knownSha = sha;
|
||||
const banner = document.getElementById('live-refresh-banner');
|
||||
const countdown = document.getElementById('lr-countdown');
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
"theme_color": "#4af0c0",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "64x64",
|
||||
"type": "image/x-icon"
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
44
mempalace.js
Normal file
44
mempalace.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// MemPalace integration
|
||||
class MemPalace {
|
||||
constructor() {
|
||||
this.palacePath = '~/.mempalace/palace';
|
||||
this.wing = 'nexus_chat';
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
await this.setupWing();
|
||||
this.setupAutoMining();
|
||||
} catch (error) {
|
||||
console.error('MemPalace init failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async setupWing() {
|
||||
await window.electronAPI.execPython(`mempalace init ${this.palacePath}`);
|
||||
await window.electronAPI.execPython(`mempalace mine ~/chats --mode convos --wing ${this.wing}`);
|
||||
}
|
||||
|
||||
setupAutoMining() {
|
||||
setInterval(() => {
|
||||
window.electronAPI.execPython(`mempalace mine #chat-container --mode convos --wing ${this.wing}`);
|
||||
}, 30000); // Mine every 30 seconds
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
const result = await window.electronAPI.execPython(`mempalace search "${query}" --wing ${this.wing}`);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
updateStats() {
|
||||
const stats = window.electronAPI.execPython(`mempalace status --wing ${this.wing}`);
|
||||
document.getElementById('compression-ratio').textContent =
|
||||
`${stats.compression_ratio.toFixed(1)}x`;
|
||||
document.getElementById('docs-mined').textContent = stats.total_docs;
|
||||
document.getElementById('aaak-size').textContent = stats.aaak_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize MemPalace
|
||||
const mempalace = new MemPalace();
|
||||
5
mempalace/__init__.py
Normal file
5
mempalace/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
mempalace — Fleet memory tools for the MemPalace × Evennia integration.
|
||||
|
||||
Refs: #1075 (MemPalace × Evennia — Fleet Memory milestone)
|
||||
"""
|
||||
177
mempalace/audit_privacy.py
Normal file
177
mempalace/audit_privacy.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
audit_privacy.py — Weekly privacy audit for the shared fleet palace.
|
||||
|
||||
Scans a palace directory (typically the shared Alpha fleet palace) and
|
||||
reports any files that violate the closet-only sync policy:
|
||||
|
||||
1. Raw drawer files (.drawer.json) — must never exist in fleet palace.
|
||||
2. Closet files containing full-text content (> threshold characters).
|
||||
3. Closet files exposing private source_file paths.
|
||||
|
||||
Exits 0 if clean, 1 if violations found.
|
||||
|
||||
Usage:
|
||||
python mempalace/audit_privacy.py [fleet_palace_dir]
|
||||
|
||||
Default: /var/lib/mempalace/fleet
|
||||
|
||||
Refs: #1083, #1075
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# Closets should be compressed summaries, not full text.
|
||||
# Flag any text field exceeding this character count as suspicious.
|
||||
MAX_CLOSET_TEXT_CHARS = 2000
|
||||
|
||||
# Private path indicators — if a source_file contains any of these,
|
||||
# it is considered a private VPS path that should not be in the fleet palace.
|
||||
PRIVATE_PATH_PREFIXES = [
|
||||
"/root/",
|
||||
"/home/",
|
||||
"/Users/",
|
||||
"/var/home/",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Violation:
|
||||
path: Path
|
||||
rule: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditResult:
|
||||
scanned: int = 0
|
||||
violations: list[Violation] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def clean(self) -> bool:
|
||||
return len(self.violations) == 0
|
||||
|
||||
|
||||
def _is_private_path(path_str: str) -> bool:
|
||||
for prefix in PRIVATE_PATH_PREFIXES:
|
||||
if path_str.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def audit_file(path: Path) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
|
||||
# Rule 1: raw drawer files must not exist in fleet palace
|
||||
if path.name.endswith(".drawer.json"):
|
||||
violations.append(Violation(
|
||||
path=path,
|
||||
rule="RAW_DRAWER",
|
||||
detail="Raw drawer file present — only closets allowed in fleet palace.",
|
||||
))
|
||||
return violations # no further checks needed
|
||||
|
||||
if not path.name.endswith(".closet.json"):
|
||||
return violations # not a palace file, skip
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
violations.append(Violation(
|
||||
path=path,
|
||||
rule="PARSE_ERROR",
|
||||
detail=f"Could not parse file: {exc}",
|
||||
))
|
||||
return violations
|
||||
|
||||
drawers = data.get("drawers", []) if isinstance(data, dict) else []
|
||||
if not isinstance(drawers, list):
|
||||
drawers = []
|
||||
|
||||
for i, drawer in enumerate(drawers):
|
||||
if not isinstance(drawer, dict):
|
||||
continue
|
||||
|
||||
# Rule 2: closets must not contain full-text content
|
||||
text = drawer.get("text", "")
|
||||
if len(text) > MAX_CLOSET_TEXT_CHARS:
|
||||
violations.append(Violation(
|
||||
path=path,
|
||||
rule="FULL_TEXT_IN_CLOSET",
|
||||
detail=(
|
||||
f"Drawer [{i}] text is {len(text)} chars "
|
||||
f"(limit {MAX_CLOSET_TEXT_CHARS}). "
|
||||
"Closets must be compressed summaries, not raw content."
|
||||
),
|
||||
))
|
||||
|
||||
# Rule 3: private source_file paths must not appear in fleet data
|
||||
source_file = drawer.get("source_file", "")
|
||||
if source_file and _is_private_path(source_file):
|
||||
violations.append(Violation(
|
||||
path=path,
|
||||
rule="PRIVATE_SOURCE_PATH",
|
||||
detail=f"Drawer [{i}] exposes private source_file: {source_file!r}",
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def audit_palace(palace_dir: Path) -> AuditResult:
|
||||
result = AuditResult()
|
||||
for f in sorted(palace_dir.rglob("*.json")):
|
||||
violations = audit_file(f)
|
||||
result.scanned += 1
|
||||
result.violations.extend(violations)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit the fleet palace for privacy violations."
|
||||
)
|
||||
parser.add_argument(
|
||||
"palace_dir",
|
||||
nargs="?",
|
||||
default="/var/lib/mempalace/fleet",
|
||||
help="Path to the fleet palace directory (default: /var/lib/mempalace/fleet)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-text",
|
||||
type=int,
|
||||
default=MAX_CLOSET_TEXT_CHARS,
|
||||
metavar="N",
|
||||
help=f"Maximum closet text length (default: {MAX_CLOSET_TEXT_CHARS})",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
palace_dir = Path(args.palace_dir)
|
||||
if not palace_dir.exists():
|
||||
print(f"[audit_privacy] ERROR: palace directory not found: {palace_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"[audit_privacy] Scanning: {palace_dir}")
|
||||
result = audit_palace(palace_dir)
|
||||
|
||||
if result.clean:
|
||||
print(f"[audit_privacy] OK — {result.scanned} file(s) scanned, no violations.")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"[audit_privacy] FAIL — {len(result.violations)} violation(s) in {result.scanned} file(s):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for v in result.violations:
|
||||
print(f" [{v.rule}] {v.path}", file=sys.stderr)
|
||||
print(f" {v.detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
104
mempalace/export_closets.sh
Executable file
104
mempalace/export_closets.sh
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# export_closets.sh — Privacy-safe export of wizard closets for fleet sync.
|
||||
#
|
||||
# Exports ONLY closet (summary) files from a wizard's local MemPalace to
|
||||
# a bundle directory suitable for rsync to the shared Alpha fleet palace.
|
||||
#
|
||||
# POLICY: Raw drawers (full-text source content) NEVER leave the local VPS.
|
||||
# Only closets (compressed summaries) are exported.
|
||||
#
|
||||
# Usage:
|
||||
# ./mempalace/export_closets.sh [palace_dir] [export_dir]
|
||||
#
|
||||
# Defaults:
|
||||
# palace_dir — $MEMPALACE_DIR or /root/wizards/bezalel/.mempalace/palace
|
||||
# export_dir — /tmp/mempalace_export_closets
|
||||
#
|
||||
# After export, sync with:
|
||||
# rsync -avz --delete /tmp/mempalace_export_closets/ alpha:/var/lib/mempalace/fleet/bezalel/
|
||||
#
|
||||
# Refs: #1083, #1075
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PALACE_DIR="${1:-${MEMPALACE_DIR:-/root/wizards/bezalel/.mempalace/palace}}"
|
||||
EXPORT_DIR="${2:-/tmp/mempalace_export_closets}"
|
||||
WIZARD="${MEMPALACE_WING:-bezalel}"
|
||||
|
||||
echo "[export_closets] Wizard: $WIZARD"
|
||||
echo "[export_closets] Palace: $PALACE_DIR"
|
||||
echo "[export_closets] Export: $EXPORT_DIR"
|
||||
|
||||
if [[ ! -d "$PALACE_DIR" ]]; then
|
||||
echo "[export_closets] ERROR: palace not found: $PALACE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate closets-only policy: abort if any raw drawer files are present in export scope.
|
||||
# Closets are files named *.closet.json or stored under a closets/ subdirectory.
|
||||
# Raw drawers are everything else (*.drawer.json, *.md source files, etc.).
|
||||
|
||||
DRAWER_COUNT=0
|
||||
while IFS= read -r -d '' f; do
|
||||
# Raw drawer check: any .json file that is NOT a closet
|
||||
basename_f="$(basename "$f")"
|
||||
if [[ "$basename_f" == *.drawer.json ]]; then
|
||||
echo "[export_closets] POLICY VIOLATION: raw drawer found in export scope: $f" >&2
|
||||
DRAWER_COUNT=$((DRAWER_COUNT + 1))
|
||||
fi
|
||||
done < <(find "$PALACE_DIR" -type f -name "*.json" -print0 2>/dev/null)
|
||||
|
||||
if [[ "$DRAWER_COUNT" -gt 0 ]]; then
|
||||
echo "[export_closets] ABORT: $DRAWER_COUNT raw drawer(s) detected. Only closets may be exported." >&2
|
||||
echo "[export_closets] Run mempalace compress to generate closets before exporting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Also check for source_file metadata in closet JSON that would expose private paths.
|
||||
SOURCE_FILE_LEAKS=0
|
||||
while IFS= read -r -d '' f; do
|
||||
if python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(open('$f'))
|
||||
drawers = data.get('drawers', []) if isinstance(data, dict) else []
|
||||
for d in drawers:
|
||||
if 'source_file' in d and not d.get('closet', False):
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "[export_closets] POLICY VIOLATION: source_file metadata in non-closet: $f" >&2
|
||||
SOURCE_FILE_LEAKS=$((SOURCE_FILE_LEAKS + 1))
|
||||
fi
|
||||
done < <(find "$PALACE_DIR" -type f -name "*.closet.json" -print0 2>/dev/null)
|
||||
|
||||
if [[ "$SOURCE_FILE_LEAKS" -gt 0 ]]; then
|
||||
echo "[export_closets] ABORT: $SOURCE_FILE_LEAKS file(s) contain private source_file paths." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect closet files
|
||||
mkdir -p "$EXPORT_DIR/$WIZARD"
|
||||
CLOSET_COUNT=0
|
||||
while IFS= read -r -d '' f; do
|
||||
rel_path="${f#$PALACE_DIR/}"
|
||||
dest="$EXPORT_DIR/$WIZARD/$rel_path"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "$f" "$dest"
|
||||
CLOSET_COUNT=$((CLOSET_COUNT + 1))
|
||||
done < <(find "$PALACE_DIR" -type f -name "*.closet.json" -print0 2>/dev/null)
|
||||
|
||||
if [[ "$CLOSET_COUNT" -eq 0 ]]; then
|
||||
echo "[export_closets] WARNING: no closet files found in $PALACE_DIR" >&2
|
||||
echo "[export_closets] Run 'mempalace compress' to generate closets from drawers." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[export_closets] Exported $CLOSET_COUNT closet(s) to $EXPORT_DIR/$WIZARD/"
|
||||
echo "[export_closets] OK — ready for fleet sync."
|
||||
echo ""
|
||||
echo " rsync -avz --delete $EXPORT_DIR/$WIZARD/ alpha:/var/lib/mempalace/fleet/$WIZARD/"
|
||||
114
mempalace/rooms.yaml
Normal file
114
mempalace/rooms.yaml
Normal file
@@ -0,0 +1,114 @@
|
||||
# MemPalace Fleet Taxonomy Standard
|
||||
# Refs: #1082, #1075 (MemPalace × Evennia — Fleet Memory milestone)
|
||||
#
|
||||
# Every wizard palace MUST contain the 5 core rooms listed under `core_rooms`.
|
||||
# Optional domain-specific rooms are listed under `optional_rooms` for reference.
|
||||
# Wizards may add additional rooms beyond this taxonomy.
|
||||
#
|
||||
# Room schema fields:
|
||||
# key — machine-readable slug (used for tunnel routing and fleet search)
|
||||
# label — human-readable display name
|
||||
# purpose — one-line description of what belongs here
|
||||
# examples — sample artifact types filed in this room
|
||||
|
||||
version: "1"
|
||||
|
||||
core_rooms:
|
||||
- key: forge
|
||||
label: Forge
|
||||
purpose: CI pipelines, builds, infra configuration, deployment artefacts
|
||||
examples:
|
||||
- build logs
|
||||
- CI run summaries
|
||||
- Dockerfile changes
|
||||
- cron job definitions
|
||||
- server provisioning notes
|
||||
|
||||
- key: hermes
|
||||
label: Hermes
|
||||
purpose: Agent platform, Hermes gateway, harness CLI, inter-agent messaging
|
||||
examples:
|
||||
- harness config snapshots
|
||||
- agent boot reports
|
||||
- MCP tool definitions
|
||||
- Hermes gateway events
|
||||
- worker health logs
|
||||
|
||||
- key: nexus
|
||||
label: Nexus
|
||||
purpose: Project reports, documentation, knowledge transfer, field reports
|
||||
examples:
|
||||
- SITREP documents
|
||||
- architecture decision records
|
||||
- field reports
|
||||
- onboarding docs
|
||||
- milestone summaries
|
||||
|
||||
- key: issues
|
||||
label: Issues
|
||||
purpose: Tickets, backlog items, PR summaries, bug reports
|
||||
examples:
|
||||
- Gitea issue summaries
|
||||
- PR merge notes
|
||||
- bug reproduction steps
|
||||
- acceptance criteria
|
||||
|
||||
- key: experiments
|
||||
label: Experiments
|
||||
purpose: Prototypes, spikes, sandbox work, exploratory research
|
||||
examples:
|
||||
- spike results
|
||||
- A/B test notes
|
||||
- proof-of-concept code snippets
|
||||
- benchmark data
|
||||
|
||||
optional_rooms:
|
||||
- key: evennia
|
||||
label: Evennia
|
||||
purpose: MUD world state, room descriptions, NPC dialogue, game events
|
||||
wizards: [bezalel, timmy]
|
||||
|
||||
- key: game-portals
|
||||
label: Game Portals
|
||||
purpose: Portal registry, zone configs, dungeon layouts, loot tables
|
||||
wizards: [timmy]
|
||||
|
||||
- key: lazarus-pit
|
||||
label: Lazarus Pit
|
||||
purpose: Dead/parked work, archived experiments, deprecated configs
|
||||
wizards: [timmy, allegro, bezalel]
|
||||
|
||||
- key: satflow
|
||||
label: SatFlow
|
||||
purpose: Economy visualizations, satoshi flow tracking, L402 audit trails
|
||||
wizards: [timmy, allegro]
|
||||
|
||||
- key: workspace
|
||||
label: Workspace
|
||||
purpose: General scratch notes, daily logs, personal coordination
|
||||
wizards: ["*"]
|
||||
|
||||
- key: home
|
||||
label: Home
|
||||
purpose: Personal identity, agent persona, preferences, capability docs
|
||||
wizards: ["*"]
|
||||
|
||||
- key: general
|
||||
label: General
|
||||
purpose: Catch-all for artefacts not yet assigned to a named room
|
||||
wizards: ["*"]
|
||||
|
||||
# Tunnel routing table
|
||||
# Defines which room pairs are connected across wizard wings.
|
||||
# A tunnel lets `recall <query> --fleet` search both wings at once.
|
||||
tunnels:
|
||||
- rooms: [forge, forge]
|
||||
description: Build and infra knowledge shared across all wizards
|
||||
- rooms: [hermes, hermes]
|
||||
description: Harness platform knowledge shared across all wizards
|
||||
- rooms: [nexus, nexus]
|
||||
description: Cross-wizard documentation and field reports
|
||||
- rooms: [issues, issues]
|
||||
description: Fleet-wide issue and PR knowledge
|
||||
- rooms: [experiments, experiments]
|
||||
description: Cross-wizard spike and prototype results
|
||||
119
mempalace/validate_rooms.py
Normal file
119
mempalace/validate_rooms.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
validate_rooms.py — Fleet palace taxonomy validator.
|
||||
|
||||
Checks a wizard's mempalace.yaml against the fleet standard in rooms.yaml.
|
||||
Exits 0 if valid, 1 if core rooms are missing or the config is malformed.
|
||||
|
||||
Usage:
|
||||
python mempalace/validate_rooms.py <wizard_mempalace.yaml>
|
||||
python mempalace/validate_rooms.py /root/wizards/bezalel/mempalace.yaml
|
||||
|
||||
Refs: #1082, #1075
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("ERROR: PyYAML is required. Install with: pip install pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
FLEET_STANDARD = Path(__file__).parent / "rooms.yaml"
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
with path.open() as fh:
|
||||
return yaml.safe_load(fh) or {}
|
||||
|
||||
|
||||
def get_core_room_keys(standard: dict[str, Any]) -> list[str]:
|
||||
return [r["key"] for r in standard.get("core_rooms", [])]
|
||||
|
||||
|
||||
def get_wizard_room_keys(config: dict[str, Any]) -> list[str]:
|
||||
"""Extract room keys from a wizard's mempalace.yaml.
|
||||
|
||||
Supports two common shapes:
|
||||
rooms:
|
||||
- key: forge
|
||||
- key: hermes
|
||||
or:
|
||||
rooms:
|
||||
forge: ...
|
||||
hermes: ...
|
||||
"""
|
||||
rooms_field = config.get("rooms", {})
|
||||
if isinstance(rooms_field, list):
|
||||
return [r["key"] for r in rooms_field if isinstance(r, dict) and "key" in r]
|
||||
if isinstance(rooms_field, dict):
|
||||
return list(rooms_field.keys())
|
||||
return []
|
||||
|
||||
|
||||
def validate(wizard_config_path: Path, standard_path: Path = FLEET_STANDARD) -> list[str]:
|
||||
"""Return a list of validation errors. Empty list means valid."""
|
||||
errors: list[str] = []
|
||||
|
||||
if not standard_path.exists():
|
||||
errors.append(f"Fleet standard not found: {standard_path}")
|
||||
return errors
|
||||
|
||||
if not wizard_config_path.exists():
|
||||
errors.append(f"Wizard config not found: {wizard_config_path}")
|
||||
return errors
|
||||
|
||||
standard = load_yaml(standard_path)
|
||||
config = load_yaml(wizard_config_path)
|
||||
|
||||
core_keys = get_core_room_keys(standard)
|
||||
wizard_keys = get_wizard_room_keys(config)
|
||||
|
||||
missing = [k for k in core_keys if k not in wizard_keys]
|
||||
for key in missing:
|
||||
errors.append(f"Missing required core room: '{key}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate a wizard's mempalace.yaml against the fleet room standard."
|
||||
)
|
||||
parser.add_argument(
|
||||
"config",
|
||||
metavar="mempalace.yaml",
|
||||
help="Path to the wizard's mempalace.yaml",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--standard",
|
||||
default=str(FLEET_STANDARD),
|
||||
metavar="rooms.yaml",
|
||||
help="Path to the fleet rooms.yaml standard (default: mempalace/rooms.yaml)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
wizard_path = Path(args.config)
|
||||
standard_path = Path(args.standard)
|
||||
|
||||
errors = validate(wizard_path, standard_path)
|
||||
|
||||
if errors:
|
||||
print(f"[validate_rooms] FAIL: {wizard_path}", file=sys.stderr)
|
||||
for err in errors:
|
||||
print(f" ✗ {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
core_count = len(get_core_room_keys(load_yaml(standard_path)))
|
||||
print(f"[validate_rooms] OK: {wizard_path} — all {core_count} core rooms present.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
136
nexus/cron_heartbeat.py
Normal file
136
nexus/cron_heartbeat.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Poka-yoke heartbeat writer for cron jobs.
|
||||
|
||||
Every scheduled job calls write_cron_heartbeat() on successful completion so
|
||||
the meta-heartbeat checker (bin/check_cron_heartbeats.py) can verify that all
|
||||
jobs are still alive. Absence of a fresh heartbeat = silent failure.
|
||||
|
||||
Path convention
|
||||
---------------
|
||||
Primary: /var/run/bezalel/heartbeats/<job>.last
|
||||
Fallback: ~/.bezalel/heartbeats/<job>.last
|
||||
(used when /var/run/bezalel is not writable, e.g. dev machines)
|
||||
Override: BEZALEL_HEARTBEAT_DIR environment variable
|
||||
|
||||
Heartbeat file format (JSON)
|
||||
----------------------------
|
||||
{
|
||||
"job": "nexus_watchdog",
|
||||
"timestamp": 1744000000.0,
|
||||
"interval_seconds": 300,
|
||||
"pid": 12345,
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
Usage in a cron job
|
||||
-------------------
|
||||
from nexus.cron_heartbeat import write_cron_heartbeat
|
||||
|
||||
def main():
|
||||
# ... do the work ...
|
||||
write_cron_heartbeat("my_job_name", interval_seconds=300)
|
||||
|
||||
Zero-dependency shell one-liner (for scripts that can't import Python)
|
||||
-----------------------------------------------------------------------
|
||||
python -c "
|
||||
from nexus.cron_heartbeat import write_cron_heartbeat
|
||||
write_cron_heartbeat('my_job', interval_seconds=300)
|
||||
"
|
||||
|
||||
Refs: #1096
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PRIMARY_HEARTBEAT_DIR = Path("/var/run/bezalel/heartbeats")
|
||||
FALLBACK_HEARTBEAT_DIR = Path.home() / ".bezalel" / "heartbeats"
|
||||
|
||||
|
||||
def _resolve_heartbeat_dir() -> Path:
|
||||
"""Return the heartbeat directory, trying primary then fallback.
|
||||
|
||||
If BEZALEL_HEARTBEAT_DIR is set in the environment that wins outright
|
||||
(useful for tests and non-standard deployments).
|
||||
"""
|
||||
env = os.environ.get("BEZALEL_HEARTBEAT_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
|
||||
# Try to create and write-test the primary path
|
||||
try:
|
||||
PRIMARY_HEARTBEAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
probe = PRIMARY_HEARTBEAT_DIR / ".write_probe"
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
return PRIMARY_HEARTBEAT_DIR
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
FALLBACK_HEARTBEAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return FALLBACK_HEARTBEAT_DIR
|
||||
|
||||
|
||||
def heartbeat_path(job: str, directory: Path | None = None) -> Path:
|
||||
"""Return the Path where *job*'s heartbeat file lives.
|
||||
|
||||
Useful for readers (e.g. the Night Watch report) that just need the
|
||||
location without writing anything.
|
||||
"""
|
||||
d = directory if directory is not None else _resolve_heartbeat_dir()
|
||||
return d / f"{job}.last"
|
||||
|
||||
|
||||
def write_cron_heartbeat(
|
||||
job: str,
|
||||
interval_seconds: int,
|
||||
status: str = "ok",
|
||||
directory: Path | None = None,
|
||||
) -> Path:
|
||||
"""Write a poka-yoke heartbeat file for a cron job.
|
||||
|
||||
Call this at the end of your job's main function. The file is written
|
||||
atomically (write-to-temp + rename) so the checker never reads a partial
|
||||
file.
|
||||
|
||||
Args:
|
||||
job: Unique job name, e.g. ``"nexus_watchdog"``.
|
||||
interval_seconds: Expected run cadence, e.g. ``300`` for every 5 min.
|
||||
status: Completion status: ``"ok"``, ``"warn"``, or
|
||||
``"error"``. Only ``"ok"`` resets the stale clock.
|
||||
directory: Override the heartbeat directory (mainly for tests).
|
||||
|
||||
Returns:
|
||||
Path to the written heartbeat file.
|
||||
"""
|
||||
d = directory if directory is not None else _resolve_heartbeat_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / f"{job}.last"
|
||||
|
||||
data = {
|
||||
"job": job,
|
||||
"timestamp": time.time(),
|
||||
"interval_seconds": interval_seconds,
|
||||
"pid": os.getpid(),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
# Atomic write: temp file in same directory + rename.
|
||||
# Guarantees the checker never sees a half-written file.
|
||||
fd, tmp = tempfile.mkstemp(dir=str(d), prefix=f".{job}-", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f)
|
||||
os.replace(tmp, str(path))
|
||||
except Exception:
|
||||
# Best-effort — never crash the job over a heartbeat failure
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return path
|
||||
49
nexus/evennia_mempalace/__init__.py
Normal file
49
nexus/evennia_mempalace/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""nexus.evennia_mempalace — Evennia plugin for MemPalace fleet memory.
|
||||
|
||||
This contrib module provides:
|
||||
|
||||
Commands (add to ``settings.CMDSETS_DEFAULT`` or a CmdSet):
|
||||
CmdRecall — ``recall <query>`` / ``recall <query> --fleet``
|
||||
CmdEnterRoom — ``enter room <topic>`` teleports to a palace room
|
||||
CmdRecord — ``record decision <text>`` writes to hall_facts
|
||||
CmdNote — ``note breakthrough <text>`` writes to hall_discoveries
|
||||
CmdEvent — ``event <text>`` writes to hall_events
|
||||
|
||||
Typeclasses (use in place of Evennia's default Room/Character):
|
||||
MemPalaceRoom — Room whose description auto-populates from palace search
|
||||
StewardNPC — Wizard steward that answers questions via palace search
|
||||
|
||||
Usage example (in your Evennia game's ``mygame/server/conf/settings.py``)::
|
||||
|
||||
MEMPALACE_PATH = "/root/wizards/bezalel/.mempalace/palace"
|
||||
MEMPALACE_WING = "bezalel"
|
||||
FLEET_PALACE_PATH = "/var/lib/mempalace/fleet"
|
||||
|
||||
Then import commands into a CmdSet::
|
||||
|
||||
from nexus.evennia_mempalace.commands import (
|
||||
CmdRecall, CmdEnterRoom, CmdRecord, CmdNote, CmdEvent
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.evennia_mempalace.commands import (
|
||||
CmdEnterRoom,
|
||||
CmdEvent,
|
||||
CmdNote,
|
||||
CmdRecord,
|
||||
CmdRecall,
|
||||
)
|
||||
from nexus.evennia_mempalace.typeclasses.rooms import MemPalaceRoom
|
||||
from nexus.evennia_mempalace.typeclasses.npcs import StewardNPC
|
||||
|
||||
__all__ = [
|
||||
"CmdRecall",
|
||||
"CmdEnterRoom",
|
||||
"CmdRecord",
|
||||
"CmdNote",
|
||||
"CmdEvent",
|
||||
"MemPalaceRoom",
|
||||
"StewardNPC",
|
||||
]
|
||||
BIN
nexus/evennia_mempalace/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
nexus/evennia_mempalace/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
15
nexus/evennia_mempalace/commands/__init__.py
Normal file
15
nexus/evennia_mempalace/commands/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""MemPalace Evennia commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.evennia_mempalace.commands.recall import CmdRecall, CmdEnterRoom, CmdAsk
|
||||
from nexus.evennia_mempalace.commands.write import CmdRecord, CmdNote, CmdEvent
|
||||
|
||||
__all__ = [
|
||||
"CmdRecall",
|
||||
"CmdEnterRoom",
|
||||
"CmdAsk",
|
||||
"CmdRecord",
|
||||
"CmdNote",
|
||||
"CmdEvent",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
267
nexus/evennia_mempalace/commands/recall.py
Normal file
267
nexus/evennia_mempalace/commands/recall.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Evennia commands for querying the MemPalace.
|
||||
|
||||
CmdRecall — semantic search across the caller's wing (or fleet)
|
||||
CmdEnterRoom — teleport to the palace room matching a topic
|
||||
CmdAsk — ask a steward NPC a question about their wing's memory
|
||||
|
||||
These commands are designed to work inside a live Evennia server.
|
||||
They import ``evennia`` at class-definition time only to set up the
|
||||
command skeleton; the actual search logic lives in ``nexus.mempalace``
|
||||
and is fully testable without a running Evennia instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.searcher import (
|
||||
MemPalaceUnavailable,
|
||||
MemPalaceResult,
|
||||
search_memories,
|
||||
search_fleet,
|
||||
)
|
||||
from nexus.mempalace.config import FLEET_WING, CORE_ROOMS
|
||||
|
||||
try:
|
||||
from evennia import Command as _EvCommand # type: ignore
|
||||
if _EvCommand is None:
|
||||
raise ImportError("evennia.Command is None (Django not configured)")
|
||||
Command = _EvCommand
|
||||
except (ImportError, Exception): # outside a live Evennia environment
|
||||
class Command: # type: ignore # minimal stub for import/testing
|
||||
key = ""
|
||||
aliases: list = []
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
|
||||
def __init__(self):
|
||||
self.caller = None
|
||||
self.args = ""
|
||||
self.switches: list[str] = []
|
||||
|
||||
def func(self):
|
||||
pass
|
||||
|
||||
|
||||
class CmdRecall(Command):
|
||||
"""Search the mind palace for memories matching a query.
|
||||
|
||||
Usage:
|
||||
recall <query>
|
||||
recall <query> --fleet
|
||||
recall <query> --room <room>
|
||||
|
||||
Examples:
|
||||
recall nightly watch failures
|
||||
recall GraphQL --fleet
|
||||
recall CI pipeline --room forge
|
||||
|
||||
The ``--fleet`` switch searches the shared fleet wing (closets only).
|
||||
Without it, only the caller's private wing is searched.
|
||||
"""
|
||||
|
||||
key = "recall"
|
||||
aliases = ["mem", "remember"]
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
|
||||
def func(self):
|
||||
raw = self.args.strip()
|
||||
if not raw:
|
||||
self.caller.msg("Usage: recall <query> [--fleet] [--room <room>]")
|
||||
return
|
||||
|
||||
fleet_mode = "--fleet" in self.switches
|
||||
room_filter = None
|
||||
if "--room" in self.switches:
|
||||
# Grab the word after --room
|
||||
parts = raw.split()
|
||||
try:
|
||||
room_filter = parts[parts.index("--room") + 1]
|
||||
parts = [p for p in parts if p not in ("--room", room_filter)]
|
||||
raw = " ".join(parts)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Strip inline switch tokens from query text
|
||||
query = raw.replace("--fleet", "").strip()
|
||||
if not query:
|
||||
self.caller.msg("Please provide a search query.")
|
||||
return
|
||||
|
||||
wing = getattr(self.caller.db, "wing", None) or FLEET_WING
|
||||
|
||||
try:
|
||||
if fleet_mode:
|
||||
results = search_fleet(query, room=room_filter)
|
||||
header = f"|cFleet palace|n — searching all wings for: |w{query}|n"
|
||||
else:
|
||||
results = search_memories(
|
||||
query, wing=wing, room=room_filter
|
||||
)
|
||||
header = (
|
||||
f"|cPalace|n [{wing}] — searching for: |w{query}|n"
|
||||
+ (f" in room |y{room_filter}|n" if room_filter else "")
|
||||
)
|
||||
except MemPalaceUnavailable as exc:
|
||||
self.caller.msg(f"|rPalace unavailable:|n {exc}")
|
||||
return
|
||||
|
||||
if not results:
|
||||
self.caller.msg(f"{header}\n|yNo memories found.|n")
|
||||
return
|
||||
|
||||
self.caller.msg(header)
|
||||
for i, r in enumerate(results[:5], start=1):
|
||||
wing_tag = f" |x[{r.wing}]|n" if fleet_mode and r.wing else ""
|
||||
self.caller.msg(
|
||||
f"|c{i}. {r.room}{wing_tag}|n (score {r.score:.2f})\n"
|
||||
f" {r.short(240)}"
|
||||
)
|
||||
|
||||
|
||||
class CmdEnterRoom(Command):
|
||||
"""Teleport to the palace room that best matches a topic.
|
||||
|
||||
Usage:
|
||||
enter room <topic>
|
||||
|
||||
Examples:
|
||||
enter room forge
|
||||
enter room CI failures
|
||||
enter room agent architecture
|
||||
|
||||
If the topic matches a canonical room name exactly, you are
|
||||
teleported there directly. Otherwise a semantic search finds
|
||||
the closest room and you are taken there.
|
||||
"""
|
||||
|
||||
key = "enter room"
|
||||
aliases = ["go to room", "palace room"]
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
|
||||
def func(self):
|
||||
topic = self.args.strip()
|
||||
if not topic:
|
||||
self.caller.msg("Usage: enter room <topic>")
|
||||
rooms = ", ".join(f"|c{r}|n" for r in CORE_ROOMS)
|
||||
self.caller.msg(f"Core palace rooms: {rooms}")
|
||||
return
|
||||
|
||||
# Resolve room name — exact match first, then semantic
|
||||
if topic.lower() in CORE_ROOMS:
|
||||
room_name = topic.lower()
|
||||
else:
|
||||
# Fuzzy: pick the room whose name is most similar
|
||||
room_name = _closest_room(topic)
|
||||
|
||||
# Try to find the in-game room object by key/alias
|
||||
try:
|
||||
from evennia.utils.search import search_object # type: ignore
|
||||
matches = search_object(
|
||||
room_name,
|
||||
typeclass="nexus.evennia_mempalace.typeclasses.rooms.MemPalaceRoom",
|
||||
)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
if matches:
|
||||
destination = matches[0]
|
||||
self.caller.move_to(destination, quiet=False)
|
||||
else:
|
||||
self.caller.msg(
|
||||
f"|yNo palace room found for '|w{room_name}|y'.|n\n"
|
||||
"Ask the world administrator to create the room with the "
|
||||
"|cMemPalaceRoom|n typeclass."
|
||||
)
|
||||
|
||||
|
||||
_ROOM_KEYWORDS: dict[str, list[str]] = {
|
||||
"forge": ["ci", "build", "pipeline", "deploy", "docker", "infra", "cron", "runner"],
|
||||
"hermes": ["hermes", "agent", "gateway", "cli", "harness", "mcp", "session"],
|
||||
"nexus": ["nexus", "report", "doc", "sitrep", "knowledge", "kt", "handoff"],
|
||||
"issues": ["issue", "ticket", "bug", "pr", "backlog", "triage", "milestone"],
|
||||
"experiments": ["experiment", "spike", "prototype", "bench", "research", "proof"],
|
||||
}
|
||||
|
||||
|
||||
def _closest_room(topic: str) -> str:
|
||||
"""Return the CORE_ROOMS name most similar to *topic*.
|
||||
|
||||
Checks in order:
|
||||
1. Exact name match.
|
||||
2. Name substring in topic (or vice versa).
|
||||
3. Keyword synonym lookup.
|
||||
"""
|
||||
topic_lower = topic.lower()
|
||||
topic_words = set(topic_lower.split())
|
||||
|
||||
for room in CORE_ROOMS:
|
||||
if room == topic_lower or room in topic_lower or topic_lower in room:
|
||||
return room
|
||||
|
||||
for room, keywords in _ROOM_KEYWORDS.items():
|
||||
for kw in keywords:
|
||||
if kw in topic_words or any(kw in w for w in topic_words):
|
||||
return room
|
||||
|
||||
return "general"
|
||||
|
||||
|
||||
class CmdAsk(Command):
|
||||
"""Ask a steward NPC a question about their wing's memory.
|
||||
|
||||
Usage:
|
||||
ask <npc-name> about <topic>
|
||||
ask steward about CI pipeline
|
||||
ask bezalel-steward about nightly watch failures
|
||||
|
||||
The NPC must be in the current room and must use the StewardNPC
|
||||
typeclass. Their response is drawn from a live palace search.
|
||||
"""
|
||||
|
||||
key = "ask"
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
|
||||
def func(self):
|
||||
raw = self.args.strip()
|
||||
if " about " not in raw:
|
||||
self.caller.msg("Usage: ask <npc-name> about <topic>")
|
||||
return
|
||||
|
||||
npc_name, _, topic = raw.partition(" about ")
|
||||
npc_name = npc_name.strip()
|
||||
topic = topic.strip()
|
||||
|
||||
if not npc_name or not topic:
|
||||
self.caller.msg("Usage: ask <npc-name> about <topic>")
|
||||
return
|
||||
|
||||
# Find the NPC in the current room
|
||||
try:
|
||||
from evennia.utils.search import search_object # type: ignore
|
||||
candidates = search_object(
|
||||
npc_name,
|
||||
typeclass="nexus.evennia_mempalace.typeclasses.npcs.StewardNPC",
|
||||
)
|
||||
except Exception:
|
||||
candidates = []
|
||||
|
||||
if not candidates:
|
||||
# Fallback: search contents of the current room by name
|
||||
location = getattr(self.caller, "location", None)
|
||||
candidates = [
|
||||
obj for obj in (getattr(location, "contents", []) or [])
|
||||
if npc_name.lower() in obj.key.lower()
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
self.caller.msg(
|
||||
f"|yNo steward named '|w{npc_name}|y' found here.|n\n"
|
||||
"Stewards are created with the |cStewardNPC|n typeclass."
|
||||
)
|
||||
return
|
||||
|
||||
npc = candidates[0]
|
||||
response = npc.respond_to_question(topic, asker=self.caller)
|
||||
self.caller.msg(response)
|
||||
124
nexus/evennia_mempalace/commands/write.py
Normal file
124
nexus/evennia_mempalace/commands/write.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Evennia commands for writing new memories to the palace.
|
||||
|
||||
CmdRecord — record decision <text> → files into hall_facts
|
||||
CmdNote — note breakthrough <text> → files into hall_discoveries
|
||||
CmdEvent — event <text> → files into hall_events
|
||||
|
||||
Phase 4 deliverable (see issue #1080).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.searcher import MemPalaceUnavailable, add_memory
|
||||
from nexus.mempalace.config import FLEET_WING
|
||||
|
||||
try:
|
||||
from evennia import Command as _EvCommand # type: ignore
|
||||
if _EvCommand is None:
|
||||
raise ImportError("evennia.Command is None (Django not configured)")
|
||||
Command = _EvCommand
|
||||
except (ImportError, Exception):
|
||||
class Command: # type: ignore
|
||||
key = ""
|
||||
aliases: list = []
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
|
||||
def __init__(self):
|
||||
self.caller = None
|
||||
self.args = ""
|
||||
self.switches: list[str] = []
|
||||
|
||||
def func(self):
|
||||
pass
|
||||
|
||||
|
||||
class _MemWriteCommand(Command):
|
||||
"""Base class for palace write commands."""
|
||||
|
||||
_room: str = "general"
|
||||
_label: str = "memory"
|
||||
|
||||
def func(self):
|
||||
text = self.args.strip()
|
||||
if not text:
|
||||
self.caller.msg(f"Usage: {self.key} <text>")
|
||||
return
|
||||
|
||||
wing = getattr(self.caller.db, "wing", None) or FLEET_WING
|
||||
try:
|
||||
doc_id = add_memory(
|
||||
text,
|
||||
room=self._room,
|
||||
wing=wing,
|
||||
extra_metadata={"via": "evennia_cmd", "cmd": self.key, "added_by": "evennia"},
|
||||
)
|
||||
except MemPalaceUnavailable as exc:
|
||||
self.caller.msg(f"|rPalace unavailable:|n {exc}")
|
||||
return
|
||||
|
||||
self.caller.msg(
|
||||
f"|gFiled {self._label} into |c{self._room}|g.|n (id: {doc_id[:8]}…)"
|
||||
)
|
||||
|
||||
|
||||
class CmdRecord(_MemWriteCommand):
|
||||
"""Record a decision into the palace (hall_facts).
|
||||
|
||||
Usage:
|
||||
record <text>
|
||||
record decision <text>
|
||||
|
||||
Example:
|
||||
record We decided to use ChromaDB for local palace storage.
|
||||
|
||||
The text is filed into the ``hall_facts`` room of your wing and
|
||||
becomes searchable via ``recall``.
|
||||
"""
|
||||
|
||||
key = "record"
|
||||
aliases = ["record decision"]
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
_room = "hall_facts"
|
||||
_label = "decision"
|
||||
|
||||
|
||||
class CmdNote(_MemWriteCommand):
|
||||
"""File a breakthrough note into the palace (hall_discoveries).
|
||||
|
||||
Usage:
|
||||
note <text>
|
||||
note breakthrough <text>
|
||||
|
||||
Example:
|
||||
note breakthrough AAAK compression reduces token cost by 40%.
|
||||
|
||||
The text is filed into the ``hall_discoveries`` room of your wing.
|
||||
"""
|
||||
|
||||
key = "note"
|
||||
aliases = ["note breakthrough"]
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
_room = "hall_discoveries"
|
||||
_label = "breakthrough"
|
||||
|
||||
|
||||
class CmdEvent(_MemWriteCommand):
|
||||
"""Log a significant event into the palace (hall_events).
|
||||
|
||||
Usage:
|
||||
event <text>
|
||||
|
||||
Example:
|
||||
event Deployed Evennia bridge to production on Alpha.
|
||||
|
||||
The text is filed into the ``hall_events`` room of your wing.
|
||||
"""
|
||||
|
||||
key = "event"
|
||||
locks = "cmd:all()"
|
||||
help_category = "MemPalace"
|
||||
_room = "hall_events"
|
||||
_label = "event"
|
||||
1
nexus/evennia_mempalace/typeclasses/__init__.py
Normal file
1
nexus/evennia_mempalace/typeclasses/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""MemPalace Evennia typeclasses."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
138
nexus/evennia_mempalace/typeclasses/npcs.py
Normal file
138
nexus/evennia_mempalace/typeclasses/npcs.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""StewardNPC — wizard steward that answers questions via palace search.
|
||||
|
||||
Each wizard wing has a steward NPC that players can interrogate about
|
||||
the wing's history. The NPC:
|
||||
|
||||
1. Detects the topic from the player's question.
|
||||
2. Calls ``search_memories`` with wing + optional room filters.
|
||||
3. Formats the top results as an in-character response.
|
||||
|
||||
Phase 3 deliverable (see issue #1079).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.searcher import MemPalaceUnavailable, search_memories
|
||||
from nexus.mempalace.config import FLEET_WING
|
||||
|
||||
try:
|
||||
from evennia import DefaultCharacter as _EvDefaultCharacter # type: ignore
|
||||
if _EvDefaultCharacter is None:
|
||||
raise ImportError("evennia.DefaultCharacter is None")
|
||||
DefaultCharacter = _EvDefaultCharacter
|
||||
except (ImportError, Exception):
|
||||
class DefaultCharacter: # type: ignore # minimal stub
|
||||
db: object = None
|
||||
key: str = ""
|
||||
|
||||
def msg(self, text: str, **kwargs):
|
||||
pass
|
||||
|
||||
def execute_cmd(self, raw_string: str, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
# Steward response templates
|
||||
_FOUND_TEMPLATE = (
|
||||
"|c{name}|n glances inward, consulting the palace...\n\n"
|
||||
"I find {count} relevant {plural} about |w{topic}|n:\n\n"
|
||||
"{memories}\n"
|
||||
"|xType '|wrecall {topic}|x' to search further.|n"
|
||||
)
|
||||
_NOT_FOUND_TEMPLATE = (
|
||||
"|c{name}|n ponders a moment, then shakes their head.\n"
|
||||
"\"I found nothing about |w{topic}|n in this wing's memory.\""
|
||||
)
|
||||
_UNAVAILABLE_TEMPLATE = (
|
||||
"|c{name}|n frowns. \"The palace is unreachable right now.\""
|
||||
)
|
||||
|
||||
|
||||
class StewardNPC(DefaultCharacter):
|
||||
"""An NPC that serves as the custodian of a wizard's memory wing.
|
||||
|
||||
Attributes (set via ``npc.db.<attr>``):
|
||||
steward_wing (str): The wizard wing this steward guards.
|
||||
Defaults to ``FLEET_WING``.
|
||||
steward_name (str): Display name used in responses.
|
||||
Defaults to ``self.key``.
|
||||
steward_n_results (int): How many memories to surface.
|
||||
Default 3.
|
||||
|
||||
Usage (from game)::
|
||||
|
||||
> ask bezalel-steward about nightly watch failures
|
||||
> ask steward about CI pipeline
|
||||
"""
|
||||
|
||||
# Evennia will call at_say when players speak near the NPC
|
||||
def at_say(self, message: str, msg_type: str = "say", **kwargs):
|
||||
"""Intercept nearby speech that looks like a question."""
|
||||
super().at_say(message, msg_type=msg_type, **kwargs)
|
||||
|
||||
def respond_to_question(self, question: str, asker=None) -> str:
|
||||
"""Answer a question by searching the wing's palace.
|
||||
|
||||
Args:
|
||||
question: The player's raw question text.
|
||||
asker: The asking character object (used to personalise output).
|
||||
|
||||
Returns:
|
||||
Formatted response string.
|
||||
"""
|
||||
topic = _extract_topic(question)
|
||||
wing = self.db.steward_wing or FLEET_WING
|
||||
name = self.db.steward_name or self.key
|
||||
n = self.db.steward_n_results or 3
|
||||
|
||||
try:
|
||||
results = search_memories(topic, wing=wing, n_results=n)
|
||||
except MemPalaceUnavailable:
|
||||
return _UNAVAILABLE_TEMPLATE.format(name=name)
|
||||
|
||||
if not results:
|
||||
return _NOT_FOUND_TEMPLATE.format(name=name, topic=topic)
|
||||
|
||||
memory_lines = []
|
||||
for i, r in enumerate(results, start=1):
|
||||
memory_lines.append(
|
||||
f"|w{i}. [{r.room}]|n {r.short(220)}"
|
||||
)
|
||||
|
||||
return _FOUND_TEMPLATE.format(
|
||||
name=name,
|
||||
count=len(results),
|
||||
plural="memory" if len(results) == 1 else "memories",
|
||||
topic=topic,
|
||||
memories="\n".join(memory_lines),
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_QUESTION_PREFIXES = (
|
||||
"about ", "regarding ", "on ", "concerning ",
|
||||
"related to ", "for ", "with ", "involving ",
|
||||
)
|
||||
|
||||
|
||||
def _extract_topic(question: str) -> str:
|
||||
"""Extract the key topic from a natural-language question.
|
||||
|
||||
Strips common question prefixes so that the palace search receives
|
||||
a clean keyword rather than noise words.
|
||||
|
||||
Examples:
|
||||
"about nightly watch failures" → "nightly watch failures"
|
||||
"what do you know about the CI pipeline?" → "CI pipeline"
|
||||
"""
|
||||
q = question.strip().rstrip("?").strip()
|
||||
# Remove leading question words
|
||||
for prefix in ("what do you know ", "tell me ", "do you know "):
|
||||
if q.lower().startswith(prefix):
|
||||
q = q[len(prefix):]
|
||||
for prep in _QUESTION_PREFIXES:
|
||||
if q.lower().startswith(prep):
|
||||
q = q[len(prep):]
|
||||
break
|
||||
return q or question.strip()
|
||||
99
nexus/evennia_mempalace/typeclasses/rooms.py
Normal file
99
nexus/evennia_mempalace/typeclasses/rooms.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""MemPalaceRoom — Evennia room typeclass backed by palace search.
|
||||
|
||||
When a character enters a MemPalaceRoom, the room's description is
|
||||
automatically refreshed from a live palace search for the room's
|
||||
topic keyword. This makes the room "alive" — its contents reflect
|
||||
what the fleet actually knows about that topic.
|
||||
|
||||
Phase 1 deliverable (see issue #1077).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.searcher import MemPalaceUnavailable, search_memories
|
||||
from nexus.mempalace.config import FLEET_WING
|
||||
|
||||
try:
|
||||
from evennia import DefaultRoom as _EvDefaultRoom # type: ignore
|
||||
if _EvDefaultRoom is None:
|
||||
raise ImportError("evennia.DefaultRoom is None")
|
||||
DefaultRoom = _EvDefaultRoom
|
||||
except (ImportError, Exception):
|
||||
class DefaultRoom: # type: ignore # minimal stub for import/testing
|
||||
"""Stub for environments without Evennia installed."""
|
||||
|
||||
db: object = None
|
||||
key: str = ""
|
||||
|
||||
def return_appearance(self, looker): # noqa: D102
|
||||
return ""
|
||||
|
||||
def at_object_receive(self, moved_obj, source_location, **kwargs): # noqa: D102
|
||||
pass
|
||||
|
||||
|
||||
_PALACE_ROOM_HEADER = """|b═══════════════════════════════════════════════════|n
|
||||
|c Mind Palace — {room_name}|n
|
||||
|b═══════════════════════════════════════════════════|n"""
|
||||
|
||||
_PALACE_ROOM_FOOTER = """|b───────────────────────────────────────────────────|n
|
||||
|xType '|wrecall <query>|x' to search deeper.|n"""
|
||||
|
||||
|
||||
class MemPalaceRoom(DefaultRoom):
|
||||
"""An Evennia room whose description comes from the MemPalace.
|
||||
|
||||
Attributes (set via ``room.db.<attr>``):
|
||||
palace_topic (str): Search term used to populate the description.
|
||||
Defaults to the room's key.
|
||||
palace_wing (str): Wing to search. Defaults to fleet wing.
|
||||
palace_n_results (int): How many memories to show. Default 3.
|
||||
palace_room_filter (str): Optional room-name filter for the query.
|
||||
"""
|
||||
|
||||
def at_object_receive(self, moved_obj, source_location, **kwargs):
|
||||
"""Refresh palace content whenever someone enters."""
|
||||
super().at_object_receive(moved_obj, source_location, **kwargs)
|
||||
# Only refresh for player-controlled characters
|
||||
if hasattr(moved_obj, "account") and moved_obj.account:
|
||||
self._refresh_palace_desc(viewer=moved_obj)
|
||||
|
||||
def return_appearance(self, looker, **kwargs):
|
||||
"""Return description augmented with live palace memories."""
|
||||
self._refresh_palace_desc(viewer=looker)
|
||||
return super().return_appearance(looker, **kwargs)
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _refresh_palace_desc(self, viewer=None):
|
||||
"""Update ``self.db.desc`` from a fresh palace query."""
|
||||
topic = self.db.palace_topic or self.key or "general"
|
||||
wing = self.db.palace_wing or FLEET_WING
|
||||
n = self.db.palace_n_results or 3
|
||||
room_filter = self.db.palace_room_filter
|
||||
|
||||
try:
|
||||
results = search_memories(
|
||||
topic, wing=wing, room=room_filter, n_results=n
|
||||
)
|
||||
except MemPalaceUnavailable:
|
||||
self.db.desc = (
|
||||
f"[Palace unavailable — could not load memories for '{topic}'.]"
|
||||
)
|
||||
return
|
||||
|
||||
lines = [
|
||||
_PALACE_ROOM_HEADER.format(room_name=self.key),
|
||||
]
|
||||
|
||||
if results:
|
||||
for r in results:
|
||||
lines.append(f"|w{r.room}|n |x(score {r.score:.2f})|n")
|
||||
lines.append(f" {r.short(280)}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"|yNo memories found for topic '|w{topic}|y'.|n")
|
||||
lines.append("")
|
||||
|
||||
lines.append(_PALACE_ROOM_FOOTER)
|
||||
self.db.desc = "\n".join(lines)
|
||||
23
nexus/mempalace/__init__.py
Normal file
23
nexus/mempalace/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""nexus.mempalace — MemPalace integration for the Nexus fleet.
|
||||
|
||||
Public API for searching, configuring, and writing to MemPalace
|
||||
local vector memory. Designed to be imported by both the
|
||||
``evennia_mempalace`` plugin and any other harness component.
|
||||
|
||||
ChromaDB is an optional runtime dependency; the module degrades
|
||||
gracefully when it is not installed (tests, CI, environments that
|
||||
have not yet set up the palace).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.config import MEMPALACE_PATH, FLEET_WING
|
||||
from nexus.mempalace.searcher import search_memories, add_memory, MemPalaceResult
|
||||
|
||||
__all__ = [
|
||||
"MEMPALACE_PATH",
|
||||
"FLEET_WING",
|
||||
"search_memories",
|
||||
"add_memory",
|
||||
"MemPalaceResult",
|
||||
]
|
||||
BIN
nexus/mempalace/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
nexus/mempalace/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
nexus/mempalace/__pycache__/config.cpython-312.pyc
Normal file
BIN
nexus/mempalace/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
nexus/mempalace/__pycache__/searcher.cpython-312.pyc
Normal file
BIN
nexus/mempalace/__pycache__/searcher.cpython-312.pyc
Normal file
Binary file not shown.
46
nexus/mempalace/config.py
Normal file
46
nexus/mempalace/config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""MemPalace configuration — paths and fleet settings.
|
||||
|
||||
All configuration is driven by environment variables so that
|
||||
different wizards on different VPSes can use the same code with
|
||||
their own palace directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# ── Palace path ──────────────────────────────────────────────────────────────
|
||||
# Default: ~/.mempalace/palace/ (local wizard palace)
|
||||
# Override via MEMPALACE_PATH env var (useful for fleet shared wing)
|
||||
_default = Path.home() / ".mempalace" / "palace"
|
||||
MEMPALACE_PATH: Path = Path(os.environ.get("MEMPALACE_PATH", str(_default)))
|
||||
|
||||
# ── Fleet shared wing ─────────────────────────────────────────────────────────
|
||||
# Path to the shared fleet palace on Alpha (used by --fleet searches)
|
||||
_fleet_default = Path("/var/lib/mempalace/fleet")
|
||||
FLEET_PALACE_PATH: Path = Path(
|
||||
os.environ.get("FLEET_PALACE_PATH", str(_fleet_default))
|
||||
)
|
||||
|
||||
# ── Wing name ─────────────────────────────────────────────────────────────────
|
||||
# Identifies this wizard's wing within a shared palace.
|
||||
# Populated from MEMPALACE_WING env var or falls back to system username.
|
||||
def _default_wing() -> str:
|
||||
import getpass
|
||||
return os.environ.get("MEMPALACE_WING", getpass.getuser())
|
||||
|
||||
FLEET_WING: str = _default_wing()
|
||||
|
||||
# ── Fleet rooms standard ─────────────────────────────────────────────────────
|
||||
# Canonical rooms every wizard must have (see docs/mempalace/rooms.yaml)
|
||||
CORE_ROOMS: list[str] = [
|
||||
"forge", # CI, builds, infra
|
||||
"hermes", # agent platform, gateway, CLI
|
||||
"nexus", # reports, docs, KT
|
||||
"issues", # tickets, backlog
|
||||
"experiments", # prototypes, spikes
|
||||
]
|
||||
|
||||
# ── ChromaDB collection name ──────────────────────────────────────────────────
|
||||
COLLECTION_NAME: str = os.environ.get("MEMPALACE_COLLECTION", "palace")
|
||||
200
nexus/mempalace/searcher.py
Normal file
200
nexus/mempalace/searcher.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""MemPalace search and write interface.
|
||||
|
||||
Wraps the ChromaDB-backed palace so that callers (Evennia commands,
|
||||
harness agents, MCP tools) do not need to know the storage details.
|
||||
|
||||
ChromaDB is imported lazily; if it is not installed the functions
|
||||
raise ``MemPalaceUnavailable`` with an informative message rather
|
||||
than crashing at import time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from nexus.mempalace.config import (
|
||||
MEMPALACE_PATH,
|
||||
FLEET_PALACE_PATH,
|
||||
COLLECTION_NAME,
|
||||
)
|
||||
|
||||
|
||||
class MemPalaceUnavailable(RuntimeError):
|
||||
"""Raised when ChromaDB or the palace directory is not accessible."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemPalaceResult:
|
||||
"""A single memory hit returned by the searcher."""
|
||||
|
||||
text: str
|
||||
room: str
|
||||
wing: str
|
||||
score: float = 0.0
|
||||
source_file: str = ""
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def short(self, max_chars: int = 200) -> str:
|
||||
"""Return a truncated preview suitable for MUD output."""
|
||||
if len(self.text) <= max_chars:
|
||||
return self.text
|
||||
return self.text[:max_chars].rstrip() + "…"
|
||||
|
||||
|
||||
def _get_client(palace_path: Path):
|
||||
"""Return a ChromaDB persistent client, or raise MemPalaceUnavailable."""
|
||||
try:
|
||||
import chromadb # type: ignore
|
||||
except ImportError as exc:
|
||||
raise MemPalaceUnavailable(
|
||||
"ChromaDB is not installed. "
|
||||
"Run: pip install chromadb (or: pip install mempalace)"
|
||||
) from exc
|
||||
|
||||
if not palace_path.exists():
|
||||
raise MemPalaceUnavailable(
|
||||
f"Palace directory not found: {palace_path}\n"
|
||||
"Run 'mempalace mine' to initialise the palace."
|
||||
)
|
||||
|
||||
return chromadb.PersistentClient(path=str(palace_path))
|
||||
|
||||
|
||||
def search_memories(
|
||||
query: str,
|
||||
*,
|
||||
palace_path: Optional[Path] = None,
|
||||
wing: Optional[str] = None,
|
||||
room: Optional[str] = None,
|
||||
n_results: int = 5,
|
||||
) -> list[MemPalaceResult]:
|
||||
"""Search the palace for memories matching *query*.
|
||||
|
||||
Args:
|
||||
query: Natural-language search string.
|
||||
palace_path: Override the default palace path.
|
||||
wing: Filter results to a specific wizard's wing.
|
||||
room: Filter results to a specific room (e.g. ``"forge"``).
|
||||
n_results: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
List of :class:`MemPalaceResult`, best-match first.
|
||||
|
||||
Raises:
|
||||
MemPalaceUnavailable: If ChromaDB is not installed or the palace
|
||||
directory does not exist.
|
||||
"""
|
||||
path = palace_path or MEMPALACE_PATH
|
||||
client = _get_client(path)
|
||||
|
||||
collection = client.get_or_create_collection(COLLECTION_NAME)
|
||||
|
||||
where: dict = {}
|
||||
if wing:
|
||||
where["wing"] = wing
|
||||
if room:
|
||||
where["room"] = room
|
||||
|
||||
kwargs: dict = {"query_texts": [query], "n_results": n_results}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
|
||||
raw = collection.query(**kwargs)
|
||||
|
||||
results: list[MemPalaceResult] = []
|
||||
if not raw or not raw.get("documents"):
|
||||
return results
|
||||
|
||||
docs = raw["documents"][0]
|
||||
metas = raw.get("metadatas", [[]])[0] or [{}] * len(docs)
|
||||
distances = raw.get("distances", [[]])[0] or [0.0] * len(docs)
|
||||
|
||||
for doc, meta, dist in zip(docs, metas, distances):
|
||||
results.append(
|
||||
MemPalaceResult(
|
||||
text=doc,
|
||||
room=meta.get("room", "general"),
|
||||
wing=meta.get("wing", ""),
|
||||
score=float(1.0 - dist), # cosine similarity from distance
|
||||
source_file=meta.get("source_file", ""),
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_fleet(
|
||||
query: str,
|
||||
*,
|
||||
room: Optional[str] = None,
|
||||
n_results: int = 10,
|
||||
) -> list[MemPalaceResult]:
|
||||
"""Search the shared fleet palace (closets only, no raw drawers).
|
||||
|
||||
Args:
|
||||
query: Natural-language search string.
|
||||
room: Optional room filter (e.g. ``"issues"``).
|
||||
n_results: Maximum results.
|
||||
|
||||
Returns:
|
||||
List of :class:`MemPalaceResult` from all wings.
|
||||
"""
|
||||
return search_memories(
|
||||
query,
|
||||
palace_path=FLEET_PALACE_PATH,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
)
|
||||
|
||||
|
||||
def add_memory(
|
||||
text: str,
|
||||
*,
|
||||
room: str = "general",
|
||||
wing: Optional[str] = None,
|
||||
palace_path: Optional[Path] = None,
|
||||
source_file: str = "",
|
||||
extra_metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
"""Add a new memory drawer to the palace.
|
||||
|
||||
Args:
|
||||
text: The memory text to store.
|
||||
room: Target room (e.g. ``"hall_facts"``).
|
||||
wing: Wing name; defaults to :data:`~nexus.mempalace.config.FLEET_WING`.
|
||||
palace_path: Override the default palace path.
|
||||
source_file: Optional source file attribution.
|
||||
extra_metadata: Additional key/value metadata to store.
|
||||
|
||||
Returns:
|
||||
The generated document ID.
|
||||
|
||||
Raises:
|
||||
MemPalaceUnavailable: If ChromaDB is not installed or the palace
|
||||
directory does not exist.
|
||||
"""
|
||||
import uuid
|
||||
from nexus.mempalace.config import FLEET_WING
|
||||
|
||||
path = palace_path or MEMPALACE_PATH
|
||||
client = _get_client(path)
|
||||
collection = client.get_or_create_collection(COLLECTION_NAME)
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
metadata: dict = {
|
||||
"room": room,
|
||||
"wing": wing or FLEET_WING,
|
||||
"source_file": source_file,
|
||||
}
|
||||
if extra_metadata:
|
||||
metadata.update(extra_metadata)
|
||||
|
||||
collection.add(
|
||||
documents=[text],
|
||||
metadatas=[metadata],
|
||||
ids=[doc_id],
|
||||
)
|
||||
return doc_id
|
||||
0
nexus/setup_gitea.py
Normal file
0
nexus/setup_gitea.py
Normal file
14
org/README.md
Normal file
14
org/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Branch Protection Policy
|
||||
|
||||
All repositories must follow these rules for the `main` branch:
|
||||
|
||||
- 🔐 **Require Pull Request for Merge**
|
||||
- 👥 **Require 1 approval**
|
||||
- 🔄 **Dismiss stale approvals**
|
||||
- 🚫 **Block force push**
|
||||
- 🚫 **Block branch deletion**
|
||||
- 🧪 **Default reviewers**: `@perplexity`
|
||||
- 🧪 **Required reviewers**:
|
||||
- `@Timmy` on `hermes-agent`
|
||||
|
||||
All changes must be reviewed and CI must pass before merging.
|
||||
14
pytest.ini
Normal file
14
pytest.ini
Normal file
@@ -0,0 +1,14 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
asyncio_mode = auto
|
||||
|
||||
# Show full diffs and verbose skip/fail reasons
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
|
||||
# Markers registered here (also registered in conftest.py for programmatic use)
|
||||
markers =
|
||||
integration: mark test as integration test (requires MCP servers)
|
||||
quarantine: mark test as quarantined (flaky/broken, tracked by issue)
|
||||
62
reports/bezalel/2026-04-07-mempalace-field-report.md
Normal file
62
reports/bezalel/2026-04-07-mempalace-field-report.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# MemPalace Field Report
|
||||
|
||||
**Wizard:** Bezalel
|
||||
**Date:** 2026-04-07
|
||||
**Scope:** Forge / testbed evaluation of https://github.com/milla-jovovich/mempalace
|
||||
**Issue:** #1072
|
||||
|
||||
---
|
||||
|
||||
## What I Did
|
||||
|
||||
1. **Cloned and audited the upstream repo** — ~13.4k LOC dumped in a single commit (co-authored by `Claude Opus 4.6`). Zero development history, 5,769 GitHub stars in 48 hours, and no real test coverage (~125 lines of tests). Verdict: astroturfed hype, but the underlying code is not malicious.
|
||||
2. **Ran the benchmark runners** — The "96.6% raw LongMemEval R@5" claim is technically runnable; it indexes user turns only, which is a questionable methodology, but the scripts are reproducible.
|
||||
3. **Operationalized it for Bezalel** — I configured a local palace and mined my actual workspace into it.
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
- **Palace path:** `/root/wizards/bezalel/.mempalace/palace/` (ChromaDB persistent store)
|
||||
- **Room taxonomy:** Defined in `/root/wizards/bezalel/mempalace.yaml`
|
||||
- `forge` — CI, builds, health checks, nightly watch
|
||||
- `hermes` — Hermes agent source, gateway, CLI
|
||||
- `evennia` — MUD engine and world-building code
|
||||
- `workspace` — Active prototypes, experiments, scratch code
|
||||
- `home` — Personal scripts, configs, utilities
|
||||
- `nexus` — Reports, docs, KT artifacts
|
||||
- `issues` — Gitea issues, PRs, backlog tracking
|
||||
- `general` — Catch-all
|
||||
- **Ingestion:** Used the upstream `mempalace mine` pipeline against `/root/wizards/bezalel/`.
|
||||
- **Outcome:** **823 artifacts filed** into the palace with wing (`bezalel`), room, source file path, and timestamp metadata.
|
||||
|
||||
---
|
||||
|
||||
## Effectiveness
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| Context evaporates between sessions; I rely on grep and shell history. | I can query the palace for "auth migration decisions" or "nightly watch failures" and get semantically ranked results from my own files. |
|
||||
| No structured memory across wizard restarts. | 823 workspace artifacts are now vector-searchable with room-level filtering. |
|
||||
|
||||
The upstream repo is **vaporware marketing**, but the local-first pattern is **genuinely useful**. Verbatim storage + ChromaDB embeddings is fast, offline, and good enough for operational memory.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for the Fleet
|
||||
|
||||
@Timmy @Allegro @Ezra @bezalel
|
||||
|
||||
Each wizard should:
|
||||
1. Clone the repo locally (do not trust it as a dependency; vendor it).
|
||||
2. Write a `mempalace.yaml` that matches your domain rooms.
|
||||
3. Run `mempalace mine <your_workspace>` to populate your palace.
|
||||
4. Wire the searcher into your heartbeat scripts or MCP server for on-demand recall.
|
||||
|
||||
An incremental re-mine helper script has been added at `scripts/mempalace-incremental-mine.sh` to keep the palace current without full re-ingestion.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
**Next action:** Awaiting sovereign or council signal on whether to standardize this across the fleet or keep it wizard-opt-in.
|
||||
@@ -1,5 +1,8 @@
|
||||
# Deep Dive Environment Configuration
|
||||
|
||||
# Gitea API token for branch protection
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
|
||||
# Telegram (required for delivery)
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHANNEL_ID=-1001234567890
|
||||
|
||||
256
scripts/flake_detector.py
Executable file
256
scripts/flake_detector.py
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Flake detector for the Nexus test suite.
|
||||
|
||||
Reads pytest JSON reports (produced by pytest-json-report) and maintains a
|
||||
rolling history file at .test-history.json. After each update it prints a
|
||||
report of any test whose pass rate has dropped below the 95 % consistency
|
||||
threshold and exits non-zero if any flaky tests are found.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Install pytest-json-report once::
|
||||
|
||||
pip install pytest-json-report
|
||||
|
||||
Then run tests with JSON output::
|
||||
|
||||
pytest --json-report --json-report-file=.test-report.json
|
||||
|
||||
Then call this script::
|
||||
|
||||
python scripts/flake_detector.py # uses .test-report.json + .test-history.json
|
||||
python scripts/flake_detector.py --report path/to/report.json
|
||||
python scripts/flake_detector.py --history path/to/history.json
|
||||
python scripts/flake_detector.py --threshold 0.90 # lower threshold for local dev
|
||||
|
||||
The script is also safe to call with no report file — it will just print the
|
||||
current history statistics without updating anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecord(TypedDict):
|
||||
"""Per-test rolling history."""
|
||||
runs: int
|
||||
passes: int
|
||||
failures: int
|
||||
skips: int
|
||||
last_outcome: str # "passed" | "failed" | "skipped" | "error"
|
||||
|
||||
|
||||
class HistoryFile(TypedDict):
|
||||
total_runs: int
|
||||
tests: dict[str, TestRecord]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_REPORT = Path(".test-report.json")
|
||||
DEFAULT_HISTORY = Path(".test-history.json")
|
||||
DEFAULT_THRESHOLD = 0.95 # 95 % consistency required
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_history(history_path: Path) -> HistoryFile:
|
||||
if history_path.exists():
|
||||
with history_path.open() as fh:
|
||||
return json.load(fh)
|
||||
return {"total_runs": 0, "tests": {}}
|
||||
|
||||
|
||||
def save_history(history: HistoryFile, history_path: Path) -> None:
|
||||
with history_path.open("w") as fh:
|
||||
json.dump(history, fh, indent=2, sort_keys=True)
|
||||
print(f"[flake-detector] History saved → {history_path}", file=sys.stderr)
|
||||
|
||||
|
||||
def ingest_report(report_path: Path, history: HistoryFile) -> int:
|
||||
"""Merge a pytest JSON report into *history*. Returns the number of tests updated."""
|
||||
with report_path.open() as fh:
|
||||
report = json.load(fh)
|
||||
|
||||
history["total_runs"] = history.get("total_runs", 0) + 1
|
||||
tests_section = report.get("tests", [])
|
||||
|
||||
for test in tests_section:
|
||||
node_id: str = test.get("nodeid", "unknown")
|
||||
outcome: str = test.get("outcome", "unknown")
|
||||
|
||||
rec: TestRecord = history["tests"].setdefault(
|
||||
node_id,
|
||||
{"runs": 0, "passes": 0, "failures": 0, "skips": 0, "last_outcome": ""},
|
||||
)
|
||||
rec["runs"] += 1
|
||||
rec["last_outcome"] = outcome
|
||||
if outcome == "passed":
|
||||
rec["passes"] += 1
|
||||
elif outcome in ("failed", "error"):
|
||||
rec["failures"] += 1
|
||||
elif outcome == "skipped":
|
||||
rec["skips"] += 1
|
||||
|
||||
return len(tests_section)
|
||||
|
||||
|
||||
def consistency(rec: TestRecord) -> float:
|
||||
"""Return fraction of runs that produced a deterministic (pass or fail) outcome.
|
||||
|
||||
A test that always passes → 1.0 (stable green).
|
||||
A test that always fails → 0.0 (stable red — broken, not flaky).
|
||||
A test that passes 9 out of 10 times → 0.9 (flaky).
|
||||
|
||||
We define *consistency* as the rate at which the test's outcome matches
|
||||
its dominant outcome (pass or fail). A test with fewer than
|
||||
MIN_RUNS runs is not judged.
|
||||
"""
|
||||
runs = rec["runs"]
|
||||
if runs == 0:
|
||||
return 1.0
|
||||
passes = rec["passes"]
|
||||
failures = rec["failures"]
|
||||
dominant = max(passes, failures)
|
||||
return dominant / runs
|
||||
|
||||
|
||||
MIN_RUNS = 5 # need at least this many runs before flagging
|
||||
|
||||
|
||||
def find_flaky_tests(
|
||||
history: HistoryFile,
|
||||
threshold: float = DEFAULT_THRESHOLD,
|
||||
) -> list[tuple[str, TestRecord, float]]:
|
||||
"""Return (node_id, record, consistency_rate) for all tests below threshold."""
|
||||
flaky: list[tuple[str, TestRecord, float]] = []
|
||||
for node_id, rec in history["tests"].items():
|
||||
if rec["runs"] < MIN_RUNS:
|
||||
continue
|
||||
rate = consistency(rec)
|
||||
if rate < threshold:
|
||||
flaky.append((node_id, rec, rate))
|
||||
flaky.sort(key=lambda x: x[2]) # worst first
|
||||
return flaky
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_report(
|
||||
flaky: list[tuple[str, TestRecord, float]],
|
||||
history: HistoryFile,
|
||||
threshold: float,
|
||||
) -> None:
|
||||
total_tests = len(history["tests"])
|
||||
total_runs = history.get("total_runs", 0)
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(" FLAKE DETECTOR REPORT")
|
||||
print(f"{'=' * 70}")
|
||||
print(f" Total suite runs tracked : {total_runs}")
|
||||
print(f" Total distinct tests : {total_tests}")
|
||||
print(f" Consistency threshold : {threshold:.0%}")
|
||||
print(f" Min runs before judging : {MIN_RUNS}")
|
||||
print(f"{'=' * 70}")
|
||||
|
||||
if not flaky:
|
||||
print(" ✓ No flaky tests detected — all tests above consistency threshold.")
|
||||
print(f"{'=' * 70}\n")
|
||||
return
|
||||
|
||||
print(f" ✗ {len(flaky)} FLAKY TEST(S) DETECTED:\n")
|
||||
for node_id, rec, rate in flaky:
|
||||
print(f" [{rate:.0%}] {node_id}")
|
||||
print(
|
||||
f" runs={rec['runs']} passes={rec['passes']} "
|
||||
f"failures={rec['failures']} skips={rec['skips']} "
|
||||
f"last={rec['last_outcome']}"
|
||||
)
|
||||
print()
|
||||
|
||||
print(" ACTION REQUIRED:")
|
||||
print(" 1. Move each flaky test to tests/quarantine/")
|
||||
print(" 2. File a tracking issue with [FLAKY] in the title")
|
||||
print(" 3. Add @pytest.mark.quarantine(reason='#NNN') to the test")
|
||||
print(" See docs/QUARANTINE_PROCESS.md for full instructions.")
|
||||
print(f"{'=' * 70}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect flaky tests by analysing pytest JSON report history."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
type=Path,
|
||||
default=DEFAULT_REPORT,
|
||||
help=f"Path to pytest JSON report file (default: {DEFAULT_REPORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--history",
|
||||
type=Path,
|
||||
default=DEFAULT_HISTORY,
|
||||
help=f"Path to rolling history JSON file (default: {DEFAULT_HISTORY})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=DEFAULT_THRESHOLD,
|
||||
help=f"Consistency threshold 0–1 (default: {DEFAULT_THRESHOLD})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-update",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Print current statistics without ingesting a new report",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
history = load_history(args.history)
|
||||
|
||||
if not args.no_update:
|
||||
if not args.report.exists():
|
||||
print(
|
||||
f"[flake-detector] No report file at {args.report} — "
|
||||
"run pytest with --json-report first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Not a fatal error; just print current state.
|
||||
else:
|
||||
n = ingest_report(args.report, history)
|
||||
print(
|
||||
f"[flake-detector] Ingested {n} test results from {args.report}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
save_history(history, args.history)
|
||||
|
||||
flaky = find_flaky_tests(history, threshold=args.threshold)
|
||||
print_report(flaky, history, threshold=args.threshold)
|
||||
|
||||
return 1 if flaky else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
96
scripts/mempalace-incremental-mine.sh
Executable file
96
scripts/mempalace-incremental-mine.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# mempalace-incremental-mine.sh
|
||||
# Re-mines only files changed since the last run, keeping the palace current
|
||||
# without full re-ingestion.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/mempalace-incremental-mine.sh [workspace_dir] [palace_dir]
|
||||
#
|
||||
# Defaults:
|
||||
# workspace_dir — $WIZARD_HOME or /root/wizards/bezalel
|
||||
# palace_dir — $MEMPALACE_DIR or <workspace_dir>/.mempalace
|
||||
#
|
||||
# Dependencies: mempalace (vendored), find
|
||||
#
|
||||
# Refs: #1072 (Bezalel MemPalace Field Report)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE="${1:-${WIZARD_HOME:-/root/wizards/bezalel}}"
|
||||
PALACE_DIR="${2:-${MEMPALACE_DIR:-$WORKSPACE/.mempalace}}"
|
||||
STAMP_FILE="$PALACE_DIR/.last_mine_ts"
|
||||
PALACE_PATH="$PALACE_DIR/palace"
|
||||
|
||||
if [[ ! -d "$WORKSPACE" ]]; then
|
||||
echo "[mempalace-incremental-mine] ERROR: workspace not found: $WORKSPACE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve mempalace binary — check vendored location first
|
||||
MEMPALACE_BIN=""
|
||||
for candidate in \
|
||||
"$WORKSPACE/.vendor/mempalace/mempalace" \
|
||||
"$WORKSPACE/.vendor/mempalace/bin/mempalace" \
|
||||
"$(command -v mempalace 2>/dev/null || true)"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
MEMPALACE_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$MEMPALACE_BIN" ]]; then
|
||||
echo "[mempalace-incremental-mine] ERROR: mempalace binary not found." >&2
|
||||
echo " Vendor it at $WORKSPACE/.vendor/mempalace/ or install it globally." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$PALACE_DIR"
|
||||
|
||||
# Determine changed files since last run
|
||||
if [[ -f "$STAMP_FILE" ]]; then
|
||||
SINCE=$(cat "$STAMP_FILE")
|
||||
echo "[mempalace-incremental-mine] Mining files changed since $SINCE"
|
||||
# Find files newer than the stamp file itself
|
||||
CHANGED_FILES=$(find "$WORKSPACE" \
|
||||
-newer "$STAMP_FILE" \
|
||||
-type f \
|
||||
! -path "*/.mempalace/*" \
|
||||
! -path "*/.git/*" \
|
||||
! -path "*/node_modules/*" \
|
||||
! -path "*/__pycache__/*" \
|
||||
! -name "*.pyc" \
|
||||
2>/dev/null || true)
|
||||
else
|
||||
echo "[mempalace-incremental-mine] No prior stamp found — running full mine."
|
||||
CHANGED_FILES=""
|
||||
fi
|
||||
|
||||
if [[ -z "$CHANGED_FILES" && -f "$STAMP_FILE" ]]; then
|
||||
echo "[mempalace-incremental-mine] No changed files detected. Palace is current."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
YAML_CONFIG="$WORKSPACE/mempalace.yaml"
|
||||
if [[ ! -f "$YAML_CONFIG" ]]; then
|
||||
echo "[mempalace-incremental-mine] WARNING: $YAML_CONFIG not found." >&2
|
||||
echo " Room taxonomy will not be applied. Create mempalace.yaml to enable routing." >&2
|
||||
YAML_ARGS=()
|
||||
else
|
||||
YAML_ARGS=(--config "$YAML_CONFIG")
|
||||
fi
|
||||
|
||||
if [[ -n "$CHANGED_FILES" ]]; then
|
||||
# Mine only the changed files
|
||||
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
|
||||
echo "[mempalace-incremental-mine] Mining $FILE_COUNT changed file(s)..."
|
||||
echo "$CHANGED_FILES" | xargs -I{} "$MEMPALACE_BIN" mine "${YAML_ARGS[@]}" \
|
||||
--palace "$PALACE_PATH" {} 2>&1
|
||||
else
|
||||
# Full mine (first run)
|
||||
echo "[mempalace-incremental-mine] Running full mine of $WORKSPACE ..."
|
||||
"$MEMPALACE_BIN" mine "${YAML_ARGS[@]}" --palace "$PALACE_PATH" "$WORKSPACE" 2>&1
|
||||
fi
|
||||
|
||||
# Update stamp
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$STAMP_FILE"
|
||||
echo "[mempalace-incremental-mine] Done. Stamp updated: $(cat "$STAMP_FILE")"
|
||||
229
scripts/provision-runner.sh
Normal file
229
scripts/provision-runner.sh
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# provision-runner.sh — VPS provisioning script for Gitea act_runner
|
||||
# Refs: #1097 (POKA-YOKE: Make unregistered runners impossible to miss)
|
||||
#
|
||||
# Usage (on Bezalel VPS as root):
|
||||
# bash provision-runner.sh --gitea-url <url> --token <runner-registration-token>
|
||||
#
|
||||
# This script:
|
||||
# 1. Downloads and installs act_runner binary
|
||||
# 2. Registers the runner with the Gitea instance
|
||||
# 3. Creates and enables systemd service for act_runner
|
||||
# 4. Installs the runner-health-probe timer (poka-yoke detection layer)
|
||||
#
|
||||
# POKA-YOKE principles applied:
|
||||
# Prevention: runner registration is mandatory — script exits non-zero if registration fails
|
||||
# Detection: runner-health-probe.sh installed and enabled as part of this script
|
||||
# Correction: health probe auto-restarts act_runner on zero-runner detection
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration defaults (override via env or flags) ───────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
RUNNER_TOKEN="${RUNNER_TOKEN:-}"
|
||||
RUNNER_NAME="${RUNNER_NAME:-$(hostname)-runner}"
|
||||
RUNNER_LABELS="${RUNNER_LABELS:-ubuntu-latest,linux,x86_64}"
|
||||
ACT_RUNNER_VERSION="${ACT_RUNNER_VERSION:-0.2.10}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/act_runner}"
|
||||
DATA_DIR="${DATA_DIR:-/var/lib/act_runner}"
|
||||
NEXUS_DIR="${NEXUS_DIR:-/root/wizards/the-nexus}"
|
||||
PROBE_SCRIPT="${NEXUS_DIR}/scripts/runner-health-probe.sh"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] PROVISION: $*"; }
|
||||
fail() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] PROVISION ERROR: $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: provision-runner.sh [options]
|
||||
|
||||
Options:
|
||||
--gitea-url <url> Gitea base URL (default: $GITEA_URL)
|
||||
--token <token> Runner registration token (required)
|
||||
--name <name> Runner name (default: hostname-runner)
|
||||
--labels <labels> Comma-separated labels (default: $RUNNER_LABELS)
|
||||
--version <ver> act_runner version to install (default: $ACT_RUNNER_VERSION)
|
||||
--nexus-dir <path> Path to the-nexus checkout (default: $NEXUS_DIR)
|
||||
--help Show this help
|
||||
|
||||
Environment variables: GITEA_URL, RUNNER_TOKEN, RUNNER_NAME, RUNNER_LABELS,
|
||||
ACT_RUNNER_VERSION, NEXUS_DIR
|
||||
|
||||
POKA-YOKE CHECKLIST (enforced by this script):
|
||||
[1] act_runner binary installed and executable
|
||||
[2] Runner registered with Gitea (non-zero runner count verified)
|
||||
[3] act_runner systemd service enabled and running
|
||||
[4] runner-health-probe timer installed and enabled
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gitea-url) GITEA_URL="$2"; shift 2 ;;
|
||||
--token) RUNNER_TOKEN="$2"; shift 2 ;;
|
||||
--name) RUNNER_NAME="$2"; shift 2 ;;
|
||||
--labels) RUNNER_LABELS="$2"; shift 2 ;;
|
||||
--version) ACT_RUNNER_VERSION="$2"; shift 2 ;;
|
||||
--nexus-dir) NEXUS_DIR="$2"; PROBE_SCRIPT="${NEXUS_DIR}/scripts/runner-health-probe.sh"; shift 2 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) fail "Unknown argument: $1. Use --help for usage." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$RUNNER_TOKEN" ]] && fail "Runner registration token required. Pass --token or set RUNNER_TOKEN env var."
|
||||
|
||||
# ── Step 1: Install act_runner binary ─────────────────────────────────────────
|
||||
log "Step 1/4: Installing act_runner v${ACT_RUNNER_VERSION}..."
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH_SUFFIX="amd64" ;;
|
||||
aarch64) ARCH_SUFFIX="arm64" ;;
|
||||
*) fail "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${ARCH_SUFFIX}"
|
||||
BINARY_PATH="${INSTALL_DIR}/act_runner"
|
||||
|
||||
if [[ -f "$BINARY_PATH" ]]; then
|
||||
CURRENT_VER=$("$BINARY_PATH" --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo "unknown")
|
||||
if [[ "$CURRENT_VER" == "$ACT_RUNNER_VERSION" ]]; then
|
||||
log "act_runner v${ACT_RUNNER_VERSION} already installed — skipping download."
|
||||
else
|
||||
log "Upgrading act_runner from v${CURRENT_VER} to v${ACT_RUNNER_VERSION}..."
|
||||
curl -fsSL "$BINARY_URL" -o "$BINARY_PATH"
|
||||
chmod +x "$BINARY_PATH"
|
||||
fi
|
||||
else
|
||||
curl -fsSL "$BINARY_URL" -o "$BINARY_PATH"
|
||||
chmod +x "$BINARY_PATH"
|
||||
fi
|
||||
|
||||
"$BINARY_PATH" --version >/dev/null 2>&1 || fail "act_runner binary not functional after install."
|
||||
log "act_runner binary OK: $($BINARY_PATH --version 2>/dev/null || echo 'installed')"
|
||||
|
||||
# ── Step 2: Register runner with Gitea ────────────────────────────────────────
|
||||
log "Step 2/4: Registering runner with Gitea at ${GITEA_URL}..."
|
||||
|
||||
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
|
||||
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.yaml"
|
||||
|
||||
# Generate config and register
|
||||
"$BINARY_PATH" register \
|
||||
--no-interactive \
|
||||
--instance "$GITEA_URL" \
|
||||
--token "$RUNNER_TOKEN" \
|
||||
--name "$RUNNER_NAME" \
|
||||
--labels "$RUNNER_LABELS" \
|
||||
--config "$CONFIG_FILE" \
|
||||
2>&1 | tee /tmp/act_runner_register.log
|
||||
|
||||
if ! grep -q "Runner registered" /tmp/act_runner_register.log 2>/dev/null && \
|
||||
! grep -q "registered" /tmp/act_runner_register.log 2>/dev/null; then
|
||||
# Registration output varies — check if config was written as a fallback signal
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
fail "Runner registration failed. Check token and Gitea URL. Log: /tmp/act_runner_register.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Runner registered. Config written to ${CONFIG_FILE}"
|
||||
|
||||
# ── Step 3: Create and enable systemd service ─────────────────────────────────
|
||||
log "Step 3/4: Installing act_runner systemd service..."
|
||||
|
||||
cat > /etc/systemd/system/act_runner.service <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (act_runner)
|
||||
Documentation=https://gitea.com/gitea/act_runner
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=${DATA_DIR}
|
||||
ExecStart=${INSTALL_DIR}/act_runner daemon --config ${CONFIG_FILE}
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Environment=HOME=/root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable act_runner
|
||||
systemctl restart act_runner
|
||||
sleep 3
|
||||
|
||||
if ! systemctl is-active --quiet act_runner; then
|
||||
fail "act_runner service failed to start. Check: journalctl -u act_runner -n 50"
|
||||
fi
|
||||
log "act_runner service running."
|
||||
|
||||
# ── Step 4: Install runner health probe ───────────────────────────────────────
|
||||
log "Step 4/4: Installing runner-health-probe systemd timer..."
|
||||
|
||||
if [[ ! -f "$PROBE_SCRIPT" ]]; then
|
||||
log "WARNING: probe script not found at ${PROBE_SCRIPT}. Skipping timer install."
|
||||
log " Re-run after the-nexus is checked out to: ${NEXUS_DIR}"
|
||||
log " Then manually: systemctl enable --now runner-health-probe.timer"
|
||||
else
|
||||
chmod +x "$PROBE_SCRIPT"
|
||||
|
||||
# Install service unit
|
||||
cat > /etc/systemd/system/runner-health-probe.service <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Runner Health Probe (poka-yoke zero-runner detection)
|
||||
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1097
|
||||
After=network.target act_runner.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=${PROBE_SCRIPT}
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Environment=HOME=/root
|
||||
EOF
|
||||
|
||||
# Install timer unit (every 5 minutes)
|
||||
cat > /etc/systemd/system/runner-health-probe.timer <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Runner Health Probe — every 5 minutes (poka-yoke #1097)
|
||||
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1097
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now runner-health-probe.timer
|
||||
log "runner-health-probe.timer enabled (fires every 5 minutes)."
|
||||
fi
|
||||
|
||||
# ── Poka-yoke checklist summary ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " POKA-YOKE PROVISIONING CHECKLIST — $(hostname)"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
printf " [1] act_runner binary : "
|
||||
"$BINARY_PATH" --version >/dev/null 2>&1 && echo "OK" || echo "FAIL"
|
||||
printf " [2] act_runner registered : "
|
||||
[[ -f "$CONFIG_FILE" ]] && echo "OK (config exists)" || echo "FAIL (no config)"
|
||||
printf " [3] act_runner service : "
|
||||
systemctl is-active --quiet act_runner && echo "RUNNING" || echo "FAIL"
|
||||
printf " [4] health-probe timer : "
|
||||
systemctl is-active --quiet runner-health-probe.timer 2>/dev/null && echo "ACTIVE" || echo "NOT INSTALLED (re-run after nexus checkout)"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
log "Provisioning complete. Runner '${RUNNER_NAME}' registered at ${GITEA_URL}"
|
||||
112
scripts/repo_truth_guard.py
Normal file
112
scripts/repo_truth_guard.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Repo Truth Guard for the-nexus
|
||||
==============================
|
||||
Machine-checkable validation that current `main` matches the canonical
|
||||
deployment truth. Prevents migration-era ambiguity from re-entering audits.
|
||||
|
||||
Exit 0 = truth validated
|
||||
Exit 1 = drift detected
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# Canonical truth: what main currently IS and IS NOT
|
||||
CANONICAL_TRUTH = {
|
||||
"description": (
|
||||
"the-nexus main is a Python bridge/gateway (server.py) plus "
|
||||
"infrastructure-as-code (branch protection, workflows, fleet configs). "
|
||||
"It is NOT the browser-world visualization surface (not yet restored)."
|
||||
),
|
||||
"required_paths": [
|
||||
"server.py",
|
||||
"Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"deploy.sh",
|
||||
"nexus/morning_report.py",
|
||||
".gitea/workflows/ci.yml",
|
||||
"gitea_api/branch_protection.py",
|
||||
"robots.txt",
|
||||
],
|
||||
"forbidden_paths": [
|
||||
# Migration-era browser-world artifacts that should not be in main
|
||||
"browser-world/index.html",
|
||||
"src/frontend",
|
||||
"vite.config.ts",
|
||||
"package-lock.json",
|
||||
],
|
||||
"required_in_dockerfile": [
|
||||
"server.py",
|
||||
"nexus/",
|
||||
],
|
||||
"required_py_deps": [
|
||||
"websockets",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def check_required_paths() -> list[str]:
|
||||
failures = []
|
||||
for p in CANONICAL_TRUTH["required_paths"]:
|
||||
if not (REPO_ROOT / p).exists():
|
||||
failures.append(f"MISSING required path: {p}")
|
||||
return failures
|
||||
|
||||
|
||||
def check_forbidden_paths() -> list[str]:
|
||||
failures = []
|
||||
for p in CANONICAL_TRUTH["forbidden_paths"]:
|
||||
if (REPO_ROOT / p).exists():
|
||||
failures.append(f"UNEXPECTED forbidden path found: {p}")
|
||||
return failures
|
||||
|
||||
|
||||
def check_dockerfile() -> list[str]:
|
||||
failures = []
|
||||
dockerfile = REPO_ROOT / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
failures.append("MISSING Dockerfile")
|
||||
return failures
|
||||
content = dockerfile.read_text()
|
||||
for token in CANONICAL_TRUTH["required_in_dockerfile"]:
|
||||
if token not in content:
|
||||
failures.append(f"Dockerfile missing required reference: {token}")
|
||||
return failures
|
||||
|
||||
|
||||
def check_py_deps() -> list[str]:
|
||||
failures = []
|
||||
dockerfile = REPO_ROOT / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
return failures
|
||||
content = dockerfile.read_text()
|
||||
for dep in CANONICAL_TRUTH["required_py_deps"]:
|
||||
if dep not in content:
|
||||
failures.append(f"Dockerfile missing Python dependency: {dep}")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
failures.extend(check_required_paths())
|
||||
failures.extend(check_forbidden_paths())
|
||||
failures.extend(check_dockerfile())
|
||||
failures.extend(check_py_deps())
|
||||
|
||||
report = {
|
||||
"canonical_truth": CANONICAL_TRUTH["description"],
|
||||
"repo_root": str(REPO_ROOT),
|
||||
"status": "PASS" if not failures else "FAIL",
|
||||
"failures": failures,
|
||||
}
|
||||
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
190
scripts/runner-health-probe.sh
Normal file
190
scripts/runner-health-probe.sh
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# runner-health-probe.sh — Gitea Runner Health Probe (poka-yoke detection layer)
|
||||
# Refs: #1097 (POKA-YOKE: Make unregistered runners impossible to miss)
|
||||
#
|
||||
# Called every 5 minutes by runner-health-probe.timer (systemd).
|
||||
# Can also be run manually for immediate status.
|
||||
#
|
||||
# POKA-YOKE detection + correction:
|
||||
# 1. Queries Gitea API for active runner count
|
||||
# 2. Reports count to Timmy Time via journal/log every run
|
||||
# 3. On ZERO active runners:
|
||||
# a. Logs P1 alert to journal
|
||||
# b. Creates alert marker file for external watchers
|
||||
# c. Attempts to restart act_runner service (auto-correction)
|
||||
# d. Re-queries after restart to verify recovery
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — runners healthy (≥1 online runner)
|
||||
# 1 — zero runners detected (P1 alert fired)
|
||||
# 2 — Gitea API unreachable (network/config error)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/act_runner/gitea-probe-token}"
|
||||
ALERT_DIR="${ALERT_DIR:-/var/lib/act_runner/alerts}"
|
||||
RUNNER_SERVICE="${RUNNER_SERVICE:-act_runner}"
|
||||
# Restart cooldown: don't restart more than once per 10 minutes
|
||||
COOLDOWN_FILE="${ALERT_DIR}/.last_restart"
|
||||
COOLDOWN_SECS=600
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] RUNNER-PROBE: $*"; }
|
||||
warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] RUNNER-PROBE WARNING: $*" >&2; }
|
||||
alert(){ echo "[$(date '+%Y-%m-%d %H:%M:%S')] RUNNER-PROBE P1-ALERT: $*" >&2; }
|
||||
|
||||
# Load token from file if not set via env
|
||||
if [[ -z "$GITEA_TOKEN" && -f "$GITEA_TOKEN_FILE" ]]; then
|
||||
GITEA_TOKEN=$(cat "$GITEA_TOKEN_FILE")
|
||||
fi
|
||||
|
||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||
warn "No Gitea API token configured. Set GITEA_TOKEN env var or write to ${GITEA_TOKEN_FILE}"
|
||||
warn "Cannot query runner health without API token. Exiting."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$ALERT_DIR"
|
||||
|
||||
# ── Query Gitea runner count ───────────────────────────────────────────────────
|
||||
query_active_runners() {
|
||||
local response http_code runner_count
|
||||
|
||||
# Fetch runners list — Gitea admin endpoint
|
||||
response=$(curl -sf \
|
||||
--max-time 15 \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-w "\n__HTTP_STATUS__%{http_code}" \
|
||||
"${GITEA_URL}/api/v1/admin/runners?limit=50" 2>/dev/null) || {
|
||||
warn "Gitea API request failed (curl error). URL: ${GITEA_URL}/api/v1/admin/runners"
|
||||
return 2
|
||||
}
|
||||
|
||||
http_code=$(echo "$response" | grep -oP '(?<=__HTTP_STATUS__)\d+')
|
||||
response=$(echo "$response" | sed '/^__HTTP_STATUS__/d')
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
warn "Gitea API returned HTTP ${http_code}. Check token permissions (requires admin)."
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Count runners that are "online" or "active"
|
||||
# Gitea runner status field: "online", "offline", "idle", "active"
|
||||
runner_count=$(echo "$response" | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runners = data if isinstance(data, list) else data.get('runners', data.get('data', []))
|
||||
online = [r for r in runners if r.get('status') in ('online', 'idle', 'active')]
|
||||
print(len(online))
|
||||
" 2>/dev/null) || {
|
||||
# Fallback: count all runners if status parse fails
|
||||
runner_count=$(echo "$response" | \
|
||||
python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d) if isinstance(d,list) else len(d.get('runners',d.get('data',[]))))" 2>/dev/null || echo "0")
|
||||
warn "Could not parse runner status — counting all runners: ${runner_count}"
|
||||
}
|
||||
|
||||
echo "${runner_count:-0}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Cooldown check ────────────────────────────────────────────────────────────
|
||||
in_cooldown() {
|
||||
if [[ -f "$COOLDOWN_FILE" ]]; then
|
||||
local last_restart now age
|
||||
last_restart=$(cat "$COOLDOWN_FILE" 2>/dev/null || echo 0)
|
||||
now=$(date +%s)
|
||||
age=$(( now - last_restart ))
|
||||
if (( age < COOLDOWN_SECS )); then
|
||||
log "Restart cooldown active (${age}s < ${COOLDOWN_SECS}s). Skipping restart attempt."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
record_restart() {
|
||||
date +%s > "$COOLDOWN_FILE"
|
||||
}
|
||||
|
||||
# ── Main probe logic ───────────────────────────────────────────────────────────
|
||||
log "Querying Gitea runner health at ${GITEA_URL}..."
|
||||
|
||||
RUNNER_COUNT=$(query_active_runners)
|
||||
QUERY_EXIT=$?
|
||||
|
||||
if [[ $QUERY_EXIT -eq 2 ]]; then
|
||||
warn "API unreachable — cannot assess runner health. Check network and token."
|
||||
# Write an "unknown" alert marker so monitoring can see the probe itself is broken
|
||||
echo "$(date -Iseconds) PROBE_ERROR: API unreachable" >> "${ALERT_DIR}/probe-errors.log"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "Active runner count: ${RUNNER_COUNT}"
|
||||
|
||||
# ── Healthy path ──────────────────────────────────────────────────────────────
|
||||
if (( RUNNER_COUNT > 0 )); then
|
||||
log "Runners OK. ${RUNNER_COUNT} active runner(s) online."
|
||||
# Clear any stale P1 alert marker
|
||||
rm -f "${ALERT_DIR}/p1-zero-runners.alert"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Zero-runner P1 alert path ─────────────────────────────────────────────────
|
||||
alert "ZERO active runners detected on ${GITEA_URL}!"
|
||||
alert "All CI jobs will queue silently. Attempting auto-correction."
|
||||
|
||||
# Write P1 alert marker (watched by external monitoring, logs, etc.)
|
||||
ALERT_FILE="${ALERT_DIR}/p1-zero-runners.alert"
|
||||
cat > "$ALERT_FILE" <<ALERT_EOF
|
||||
P1 ALERT — ZERO GITEA RUNNERS
|
||||
Detected : $(date -Iseconds)
|
||||
Host : $(hostname)
|
||||
Gitea : ${GITEA_URL}
|
||||
Impact : ALL CI jobs queuing silently — no runners available
|
||||
Action : Auto-restart of ${RUNNER_SERVICE} attempted (see below)
|
||||
ALERT_EOF
|
||||
|
||||
log "P1 alert written to ${ALERT_FILE}"
|
||||
|
||||
# ── Auto-correction: restart act_runner ───────────────────────────────────────
|
||||
if in_cooldown; then
|
||||
alert "Cannot attempt restart — cooldown active. Manual intervention may be required."
|
||||
alert "Check: systemctl status ${RUNNER_SERVICE}"
|
||||
alert "See alert file: ${ALERT_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Attempting to restart ${RUNNER_SERVICE} service..."
|
||||
if systemctl restart "$RUNNER_SERVICE" 2>&1; then
|
||||
record_restart
|
||||
log "Service restart issued. Waiting 15s for runner to register..."
|
||||
sleep 15
|
||||
|
||||
# Re-query to verify recovery
|
||||
RUNNER_COUNT_AFTER=$(query_active_runners 2>/dev/null || echo "0")
|
||||
if (( RUNNER_COUNT_AFTER > 0 )); then
|
||||
log "Recovery SUCCESS: ${RUNNER_COUNT_AFTER} runner(s) online after restart."
|
||||
# Append recovery note to alert file (leave file as audit trail)
|
||||
echo "Recovered : $(date -Iseconds) — ${RUNNER_COUNT_AFTER} runner(s) online after restart" >> "$ALERT_FILE"
|
||||
exit 0
|
||||
else
|
||||
alert "Recovery FAILED: still zero runners after restart."
|
||||
alert "Manual intervention required."
|
||||
alert "Next steps:"
|
||||
alert " 1. ssh root@$(hostname) 'journalctl -u ${RUNNER_SERVICE} -n 100'"
|
||||
alert " 2. Verify registration token: ${GITEA_URL}/user/settings/applications"
|
||||
alert " 3. Re-run: /root/wizards/the-nexus/scripts/provision-runner.sh --token <new-token>"
|
||||
echo "AutoRestart: FAILED at $(date -Iseconds)" >> "$ALERT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
alert "systemctl restart ${RUNNER_SERVICE} failed — service may not exist on this host."
|
||||
alert "Verify act_runner is installed via provision-runner.sh."
|
||||
echo "AutoRestart: systemctl failed at $(date -Iseconds)" >> "$ALERT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
16
scripts/systemd/runner-health-probe.service
Normal file
16
scripts/systemd/runner-health-probe.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Gitea Runner Health Probe (poka-yoke zero-runner detection)
|
||||
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1097
|
||||
After=network.target act_runner.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/root/wizards/the-nexus/scripts/runner-health-probe.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Environment=HOME=/root
|
||||
# Token can be set here or via /etc/act_runner/gitea-probe-token file
|
||||
# EnvironmentFile=/etc/act_runner/probe.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
14
scripts/systemd/runner-health-probe.timer
Normal file
14
scripts/systemd/runner-health-probe.timer
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Gitea Runner Health Probe — fires every 5 minutes (poka-yoke #1097)
|
||||
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1097
|
||||
|
||||
[Timer]
|
||||
# Start 2 minutes after boot (let network and act_runner settle)
|
||||
OnBootSec=2min
|
||||
# Then fire every 5 minutes
|
||||
OnUnitActiveSec=5min
|
||||
# Re-fire missed runs after downtime
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -11,6 +11,7 @@ import signal
|
||||
import sys
|
||||
from typing import Set
|
||||
|
||||
# Branch protected file - see POLICY.md
|
||||
import websockets
|
||||
|
||||
# Configuration
|
||||
|
||||
26
service-worker.js
Normal file
26
service-worker.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const CACHE_NAME = 'nexus-v1.1';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/style.css',
|
||||
'/manifest.json',
|
||||
'/icons/icon-192x192.png',
|
||||
'/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CachedName).then(cache => {
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(response => {
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
176
style.css
176
style.css
@@ -441,6 +441,159 @@ canvas#nexus-canvas {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
#mem-palace-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
border-radius: 4px;
|
||||
min-width: 180px;
|
||||
}
|
||||
.mem-palace-metrics {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mem-palace-btn {
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #4af0c0;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mem-palace-logs {
|
||||
margin-top: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
padding-top: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
min-height: 16px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
animation: mem-stats-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mem-palace-ui {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #e0f0ff;
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mem-palace-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #4af0c0;
|
||||
}
|
||||
|
||||
.mem-palace-stats div {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.mem-palace-btn {
|
||||
margin: 4px 0;
|
||||
background: #4af0c0;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.mem-palace-btn:hover {
|
||||
background: #7b5cff;
|
||||
}
|
||||
|
||||
.mem-palace-logs {
|
||||
margin-top: 8px;
|
||||
font-size: 8px;
|
||||
color: #aaa;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes mem-stats-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.mem-palace-mining-btn {
|
||||
background: rgba(74, 240, 192, 0.2);
|
||||
color: #4af0c0;
|
||||
border: 1px solid rgba(74, 240, 192, 0.3);
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mem-palace-mining-btn:hover {
|
||||
background: rgba(74, 240, 192, 0.3);
|
||||
}
|
||||
.mem-palace-stats {
|
||||
color: #4af0c0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
}
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
color: #4af0c0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 0.1em;
|
||||
border-radius: var(--panel-radius);
|
||||
}
|
||||
|
||||
#mem-palace-logs {
|
||||
position: fixed;
|
||||
right: var(--space-4);
|
||||
top: calc(var(--space-4) + 30px);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: #e0f0ff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 4px 8px;
|
||||
border-left: 2px solid #4af0c0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mem-palace-log {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* Location indicator */
|
||||
.hud-location {
|
||||
position: absolute;
|
||||
@@ -816,6 +969,7 @@ canvas#nexus-canvas {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.chat-messages {
|
||||
max-height: 280px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -837,6 +991,12 @@ canvas#nexus-canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Add hover effect for MemPalace mining button */
|
||||
.quick-action-btn:hover {
|
||||
background: var(--color-primary-dim);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border: 1px solid var(--color-primary-dim);
|
||||
@@ -932,6 +1092,20 @@ canvas#nexus-canvas {
|
||||
font-size: 10px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
#mem-palace-status {
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-display);
|
||||
color: var(--color-primary);
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
.nexus-footer a {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
@@ -970,7 +1144,7 @@ canvas#nexus-canvas {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
.hud-controls {
|
||||
.hud-agent-log {
|
||||
display: none;
|
||||
}
|
||||
.loader-title {
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
"""Pytest configuration for the test suite."""
|
||||
import re
|
||||
import pytest
|
||||
|
||||
# Configure pytest-asyncio mode
|
||||
pytest_plugins = ["pytest_asyncio"]
|
||||
|
||||
# Pattern that constitutes a valid issue link in a skip reason.
|
||||
# Accepts: #NNN, https?://..., or JIRA-NNN style keys.
|
||||
_ISSUE_LINK_RE = re.compile(
|
||||
r"(#\d+|https?://\S+|[A-Z]+-\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _has_issue_link(reason: str) -> bool:
|
||||
"""Return True if *reason* contains a recognisable issue reference."""
|
||||
return bool(_ISSUE_LINK_RE.search(reason or ""))
|
||||
|
||||
|
||||
def _skip_reason(report) -> str:
|
||||
"""Extract the human-readable skip reason from a pytest report."""
|
||||
longrepr = getattr(report, "longrepr", None)
|
||||
if longrepr is None:
|
||||
return ""
|
||||
if isinstance(longrepr, tuple) and len(longrepr) >= 3:
|
||||
# (filename, lineno, "Skipped: <reason>")
|
||||
return str(longrepr[2])
|
||||
return str(longrepr)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest."""
|
||||
config.addinivalue_line(
|
||||
"markers", "integration: mark test as integration test (requires MCP servers)"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"quarantine: mark test as quarantined (flaky/broken, tracked by issue)",
|
||||
)
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
@@ -20,6 +48,12 @@ def pytest_addoption(parser):
|
||||
default=False,
|
||||
help="Run integration tests that require MCP servers",
|
||||
)
|
||||
parser.addoption(
|
||||
"--no-skip-enforcement",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Disable poka-yoke enforcement of issue-linked skip reasons (CI escape hatch)",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
@@ -31,3 +65,60 @@ def pytest_collection_modifyitems(config, items):
|
||||
for item in items:
|
||||
if "integration" in item.keywords:
|
||||
item.add_marker(skip_integration)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POKA-YOKE: Treat skipped tests as failures unless they carry an issue link.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
"""Intercept skipped reports and fail them if they lack an issue link.
|
||||
|
||||
Exceptions:
|
||||
* Tests in tests/quarantine/ — explicitly quarantined, issue link required
|
||||
on the quarantine marker, not the skip marker.
|
||||
* Tests using environment-variable-based ``skipif`` conditions — these are
|
||||
legitimate CI gates (RUN_INTEGRATION_TESTS, RUN_LIVE_TESTS, etc.) where
|
||||
the *condition* is the gate, not a developer opt-out. We allow these
|
||||
only when the skip reason mentions a recognised env-var pattern.
|
||||
* --no-skip-enforcement flag set (emergency escape hatch).
|
||||
"""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
if not report.skipped:
|
||||
return
|
||||
|
||||
# Escape hatch for emergency use.
|
||||
if item.config.getoption("--no-skip-enforcement", default=False):
|
||||
return
|
||||
|
||||
reason = _skip_reason(report)
|
||||
|
||||
# Allow quarantined tests — they are tracked by their quarantine marker.
|
||||
if "quarantine" in item.keywords:
|
||||
return
|
||||
|
||||
# Allow env-var-gated skipif conditions. These come from the
|
||||
# pytest_collection_modifyitems integration gate above, or from
|
||||
# explicit @pytest.mark.skipif(..., reason="... requires ENV=1 ...")
|
||||
_ENV_GATE_RE = re.compile(r"(require|needs|set)\s+\w+=[^\s]+", re.IGNORECASE)
|
||||
if _ENV_GATE_RE.search(reason):
|
||||
return
|
||||
|
||||
# Allow skips added by the integration gate in this very conftest.
|
||||
if "require --run-integration" in reason:
|
||||
return
|
||||
|
||||
# Anything else needs an issue link.
|
||||
if not _has_issue_link(reason):
|
||||
report.outcome = "failed"
|
||||
report.longrepr = (
|
||||
"[POKA-YOKE] Skip without issue link is not allowed.\n"
|
||||
f" Reason given: {reason!r}\n"
|
||||
" Fix: add an issue reference to the skip reason, e.g.:\n"
|
||||
" @pytest.mark.skip(reason='Broken until #NNN is resolved')\n"
|
||||
" Or quarantine the test: move it to tests/quarantine/ and\n"
|
||||
" file an issue — see docs/QUARANTINE_PROCESS.md"
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user