Compare commits

...
Author SHA1 Message Date
Nick 65b76afb8c Merge remote-tracking branch 'gitea/master' into bugfix-contract
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 58s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / canary (push) Failing after 35s
CI Pipeline / lint (push) Successful in 41s
CI Pipeline / unit-tests (push) Failing after 47s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / required-checks (push) Skipped
2026-09-07 21:00:58 +01:00
Nick ff49fba738 style: ruff format added files (CI lint format-check) 2026-09-07 20:46:57 +01:00
Nick 62abca0538 style: ruff format discharge checker (CI lint format-check)
CI Pipeline / lint (push) Failing after 17s
CI Pipeline / unit-tests (push) Successful in 55s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Failing after 21s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Failing after 24s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / type-check (pull_request) Failing after 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Skipped
2026-09-07 20:46:41 +01:00
conductor de8c7a6e47 Merge pull request 'Merge-robot fail-loud discharge: identity check, refusals fail the job (bug-fix contract first customer)' (#211) from merge-robot-fix into master
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 36s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 43s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / required-checks (push) Skipped
2026-09-07 20:46:26 +01:00
Nick 7402a68095 fix(pipeline): merge-robot fails loudly on refusals, verifies token identity, extracted testable script — red-first discharge of the silent no-op (bug-fix contract) (#147)
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 31s
CI Pipeline / canary (pull_request) Successful in 30s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 18:14:10 +01:00
Nick dac9bec488 feat(contract): pipeline-expansion henxels — workflows/TUI registries, module-coverage rule with shrinking debt ledger (#204, #147)
CI Pipeline / lint (push) Successful in 39s
CI Pipeline / unit-tests (push) Successful in 39s
CI Pipeline / canary (push) Successful in 1m3s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Failing after 17s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Skipped
2026-09-07 17:46:46 +01:00
Nick a9fe30bb7c feat(contract): binding refinements (soak-close, escape accountability, fresh-context RCA, deja-vu, fixture promotion) + henxels rules fitted to pipeline (#147)
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 56s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Failing after 17s
CI Pipeline / unit-tests (pull_request) Successful in 31s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Skipped
2026-09-07 17:15:23 +01:00
Nick a78588048b feat(contract): bug-fix discharge contract — red-first fixes, full pipeline re-entry, frozen regression tests (#147)
CI Pipeline / lint (push) Failing after 17s
CI Pipeline / unit-tests (push) Successful in 55s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 40s
CI Pipeline / gitea-offline (push) Successful in 20s
CI Pipeline / required-checks (push) Skipped
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Failing after 17s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Skipped
2026-09-07 16:59:04 +01:00
conductor 2975d7651d Merge pull request 'Merge robot: deterministic merge-on-green' (#203) from merge-robot into master
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 37s
CI Pipeline / unit-tests (push) Failing after 36s
CI Pipeline / canary (push) Failing after 30s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / required-checks (push) Skipped
2026-09-07 14:12:35 +01:00
Nick 9df9181767 Merge remote-tracking branch 'gitea/master' into merge-robot
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 31s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:59:23 +01:00
conductor 9ce673f632 Merge pull request 'Pipeline gate jobs on desktop runner (latency)' (#202) from runner-desktop-gate into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-07 13:58:24 +01:00
Nick fd8bd001cb feat(pipeline): deterministic merge-robot — green PRs merge without agent turns (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 20s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 52s
CI Pipeline / canary (pull_request) Successful in 33s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:52:59 +01:00
Nick e33b563dad feat(pipeline): gate jobs on desktop runner — latency (Pi measured ~15min/6 API calls poisons event-driven gates) (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 26s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:31:24 +01:00
conductor f2dc1621fd Merge pull request 'Research gate: evidence enforcement (OUTWARD sources required)' (#201) from research-gate-evidence into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 58s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 1s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 12:49:16 +01:00
Nick b77f4b43ef feat(pipeline): synthesis kill-test requirement + loop-back bound with owner escalation (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 24s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 25s
CI Pipeline / unit-tests (pull_request) Successful in 33s
CI Pipeline / canary (pull_request) Successful in 32s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 12:40:07 +01:00
Nick e497bd8c56 feat(pipeline): synthesis qualification gate — evidence sufficiency, feasibility verdict, honest MORE-RESEARCH loop-back (#147)
CI Pipeline / lint (push) Successful in 25s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 26s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 32s
CI Pipeline / canary (pull_request) Successful in 1m30s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 12:32:30 +01:00
Nick 3e647281ce feat(pipeline): research gate verifies source reachability + per-source trail of use (#147)
CI Pipeline / lint (push) Successful in 44s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 43s
CI Pipeline / unit-tests (pull_request) Successful in 1m5s
CI Pipeline / canary (pull_request) Successful in 36s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 32s
CI Pipeline / gitea-offline (pull_request) Successful in 21s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 12:19:24 +01:00
Nick 25684bf1f0 feat(pipeline): research gate requires evidence (sources in OUTWARD, citations in INWARD) (#147)
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 31s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Successful in 27s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 12:13:34 +01:00
conductor 2840f71ccf Merge pull request 'P1-02: AttentionCapsule as the real model product (closes #25)' (#200) from p1-02-capsule-authority into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Successful in 29s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 20s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 10:03:58 +01:00
Nick b122aeb904 docs(p1-02): plan completion record (#25)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 09:59:03 +01:00
Nick 0434aac072 feat(p1-02): canary + CI registration (#25) 2026-09-07 09:59:03 +01:00
Nick 1db30c14f4 feat(p1-02): capsule authority map + docs (#25) 2026-09-07 09:59:03 +01:00
Nick 21c40231a5 feat(p1-02): trace v4 capsule fidelity fields (#25) 2026-09-07 09:59:03 +01:00
Nick 340646a7b1 feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25) 2026-09-07 09:59:03 +01:00
Nick 32f155d95b feat(p1-02): capsule content logging + message capture + legacy deprecation (#25) 2026-09-07 09:59:03 +01:00
conductor 6b191994d8 Merge pull request 'feat(pipeline): automatic stage transitions — in-progress, incomplete ledger, notification comments' (#198) from feat/pipeline-transitions into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 32s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / canary (push) Successful in 28s
Auto-label / auto-label (pull_request) Successful in 1s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 09:42:01 +01:00
conductor 4f9ddcde1f feat(pipeline): automatic stage transitions — in-progress label, incomplete-work catcher, notification comments
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 27s
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 25s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 09:34:32 +01:00
conductor 5c293df56b Merge pull request 'fix(pipeline): research gate wording — Rune does the research, not the user' (#195) from fix/research-gate-wording into master
CI Pipeline / lint (push) Successful in 26s
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 31s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 25s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 09:00:58 +01:00
conductor f1e17b03d4 fix(pipeline): research gate wording — Rune does the research, not the user
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 46s
CI Pipeline / unit-tests (push) Failing after 20s
CI Pipeline / canary (push) Successful in 41s
CI Pipeline / best-practices (push) Successful in 17s
CI Pipeline / documentation (push) Successful in 8s
CI Pipeline / type-check (push) Successful in 43s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 35s
CI Pipeline / unit-tests (pull_request) Successful in 45s
CI Pipeline / canary (pull_request) Successful in 38s
CI Pipeline / best-practices (pull_request) Successful in 10s
CI Pipeline / documentation (pull_request) Successful in 9s
CI Pipeline / type-check (pull_request) Successful in 29s
CI Pipeline / gitea-offline (pull_request) Successful in 17s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 08:46:25 +01:00
conductor dca51f1801 Merge pull request 'feat(pipeline): research-round gate — auto-apply research-pending on ready, auto-clear on 4-phase research comment' (#194) from feat/research-round-gate into master
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 32s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 21s
CI Pipeline / required-checks (push) Skipped
2026-09-06 23:55:03 +01:00
conductor 56177ee1a4 feat(pipeline): research-round gate — auto-apply research-pending on ready, auto-clear on 4-phase research comment (RESEARCH ROUND V3)
CI Pipeline / lint (push) Successful in 24s
Auto-label / auto-label (pull_request) Successful in 2s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 22s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 1s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 20s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-06 23:47:57 +01:00
32 changed files with 2352 additions and 67 deletions

No files matched your search

+1
View File
@@ -90,6 +90,7 @@ jobs:
src/rune/canary_p0_04.py \
src/rune/canary_p0_05.py \
src/rune/canary_p0_07.py \
src/rune/canary_p1_02.py \
src/rune/canary_p1_07.py \
src/rune/canary_p1_06.py \
src/rune/canary_p1_01.py \
+34
View File
@@ -0,0 +1,34 @@
name: Merge robot
# Deterministic merge-on-green for this repo's PRs. No LLM, no polling by the
# agent: the forge event model does the watching, this job does the one action.
# Runs hourly + on CI completion events so green PRs merge without any agent
# turn being spent on "check CI, merge, clean up".
on:
schedule:
- cron: '23 * * * *'
workflow_dispatch: {}
workflow_run:
workflows: ["CI Pipeline", "Auto-version and Release"]
types: [completed]
permissions:
pull_requests: write
issues: write
contents: read
env:
OWNER: conductor
REPO: rune
jobs:
merge-green:
runs-on: rune-ci-desktop
steps:
- name: Merge PRs whose protected checks are green (fail-loud)
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
SERVER: ${{ gitea.server_url }}
run: |
python3 scripts/merge_robot.py "$GITEA_TOKEN" "$SERVER/api/v1/repos/conductor/rune"
+311 -3
View File
@@ -3,6 +3,8 @@ name: Pipeline Order Gate
on:
issues:
types: [labeled, closed]
issue_comment:
types: [created]
schedule:
# Hourly sweep: catches newly-eligible issues when a dependency closed
# while the teacher was down/not yet deployed (temporal gap fix).
@@ -16,7 +18,7 @@ jobs:
# `Depends on: #N` reference in its body is CLOSED. Otherwise Gitea
# blocks the stage (removes ready, adds blocked, names the blocker).
gate:
runs-on: rune-infra-pi
runs-on: rune-ci-desktop
steps:
- name: Verify dependency order before allowing ready
uses: actions/github-script@v7
@@ -58,9 +60,15 @@ jobs:
}
}
if (openDeps.length === 0) {
// Auto-apply research-pending: the issue cannot start until a
// 4-phase research round (INWARD / OUTWARD / INNOVATION / SYNTHESIS)
// is completed and posted as a comment.
await github.rest.issues.addLabels({
owner, repo, issue_number: issue.number, labels: ['research-pending'],
}).catch(e => { if (e.status !== 404) throw e; });
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: `🟢 **READY — order satisfied; Rune may start.** Dependencies: ${uniqueDeps.length ? uniqueDeps.map(n => '#' + n).join(', ') : 'none'}.`,
body: `🟢 **READY — order satisfied.** Dependencies: ${uniqueDeps.length ? uniqueDeps.map(n => '#' + n).join(', ') : 'none'}.\n\n📋 **Research round required** (RESEARCH ROUND V3 — 4 phases):\n1. **INWARD** — codebase analysis\n2. **OUTWARD** — state-of-the-art survey (libraries, papers, production systems; "is there anything BETTER?")\n3. **INNOVATION** — viability-filtered improvements (evaluate, optionally implement)\n4. **SYNTHESIS** — specific build instructions\n\nRune (the agent) performs the research and posts the findings as a comment on this issue. The \`research-pending\` label will be cleared automatically and the issue becomes startable.`,
});
return;
}
@@ -82,7 +90,7 @@ jobs:
# issue (open, has the orchestration-stage marker in its body, all deps
# closed) and label it ready — the order teacher keeps Rune on task.
teach:
runs-on: rune-infra-pi
runs-on: rune-ci-desktop
steps:
- name: Announce the next issue in dependency order
uses: actions/github-script@v7
@@ -154,3 +162,303 @@ jobs:
body: (() => { const closed = context.payload.issue ? ' (closed: #' + context.payload.issue.number + ')' : ' (scheduled sweep)'; return `🎓 **NEXT: #${next.number} — ${next.title}** — dependencies satisfied${closed}; Rune may start.`; })(),
});
core.info(`Announced #${next.number} as next.`);
# RESEARCH CLEAR: when a comment containing the 4-phase research markers
# lands on a research-pending issue, clear the label and announce start.
research-clear:
runs-on: rune-ci-desktop
if: github.event_name == 'issue_comment' && github.event.action == 'created'
steps:
- name: Clear research-pending when research round is posted
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (!issue) { core.info('no issue in payload'); return; }
const comment = context.payload.comment?.body || '';
// --- Phase header detection (case-insensitive) ---
const hasInward = /\bINWARD\b/i.test(comment);
const hasOutward = /\bOUTWARD\b/i.test(comment);
const hasInnovation = /\bINNOVATION\b/i.test(comment);
const hasSynthesis = /\bSYNTHESIS\b/i.test(comment);
if (!hasInward || !hasOutward || !hasInnovation || !hasSynthesis) {
core.info(`research-clear: comment on #${issue.number} missing phases (inward=${hasInward} outward=${hasOutward} innovation=${hasInnovation} synthesis=${hasSynthesis}) — skip`);
return;
}
// --- Evidence enforcement (per phase, section-scoped) ---
// Split the comment into phase sections on the phase headers.
function section(phase) {
const re = new RegExp(`\\b${phase}\\b[\\s\\S]*?(?=\\b(?:INWARD|OUTWARD|INNOVATION|SYNTHESIS)\\b|$)`, 'i');
const m = comment.match(re);
return m ? m[0] : '';
}
const forgeHost = context.payload.repository?.html_url ? new URL(context.payload.repository.html_url).host : '';
const externalUrlRe = /https?:\/\/[^\s)\]>]+/gi;
const codePathRe = /(?:^|\s)(?:src|tests|scripts|docs|\.gitea)\/[\w./-]+/gi;
const plansPathRe = /(?:^|\s)plans\/[\w./-]+/gi;
function uniqueExternalUrls(text) {
const found = text.match(externalUrlRe) || [];
const uniq = new Map(); // normalized key -> original URL (fetchable)
for (const u of found) {
try {
const parsed = new URL(u);
if (parsed.host && parsed.host !== forgeHost) {
const key = parsed.host + parsed.pathname;
if (!uniq.has(key)) uniq.set(key, u);
}
} catch (e) { /* skip malformed */ }
}
return uniq;
}
function codeCitations(text) {
return new Set((text.match(codePathRe) || []).map(s => s.trim()));
}
const inwardSec = section('INWARD');
const outwardSec = section('OUTWARD');
const innovationSec = section('INNOVATION');
const synthesisSec = section('SYNTHESIS');
const tailSec = innovationSec + '\n' + synthesisSec;
const inwardCites = codeCitations(inwardSec);
const outwardUrls = uniqueExternalUrls(outwardSec);
const tailUrls = uniqueExternalUrls(tailSec);
const tailPlans = (tailSec.match(plansPathRe) || []).length;
const missing = [];
if (inwardCites.size < 2) missing.push(`- **INWARD**: needs ≥2 codebase citations (paths like src/..., tests/..., .gitea/...) — found ${inwardCites.size}.`);
if (outwardUrls.size < 2) missing.push(`- **OUTWARD**: needs ≥2 UNIQUE external source URLs (not this forge) — found ${outwardUrls.size}. Research must pull real sources to learn from so later stages build on evidence.`);
if (tailUrls.size < 1 && tailPlans < 1) missing.push(`- **INNOVATION/SYNTHESIS**: needs ≥1 source URL reference or a plans/ artifact path tying synthesis back to evidence.`);
// --- INNOVATION must be source-tied: ≥1 external URL from OUTWARD
// re-referenced (proving candidates derive from pulled sources, not invention).
const innovationUrlKeys = [...uniqueExternalUrls(innovationSec).keys()];
const tiedToOutward = innovationUrlKeys.filter(k => outwardUrls.has(k));
if (outwardUrls.size >= 2 && tiedToOutward.length < 1) {
missing.push(`- **INNOVATION**: candidate approaches must derive from OUTWARD sources — re-reference ≥1 of the OUTWARD source URLs here and say what it contributed. Found 0 source-tied candidates.`);
}
// --- SYNTHESIS qualification gate: the synthesis must (a) declare its
// evidence sufficiency, (b) judge feasibility REALISTICALLY (mechanism +
// assumptions + failure modes), and (c) either commit to a plan artifact
// or honestly bounce to MORE RESEARCH. Evidence-insufficient synthesis
// MUST declare MORE RESEARCH — bluffing through fails the gate.
const SYNTH_REJECTS = [];
const suffMarker = /\bevidence[^\n]{0,40}(suffic|adequat|insuffic)|insufficient evidence|evidence gap/i;
const hasSufficiency = suffMarker.test(synthesisSec);
const moreResearch = /\bMORE[- ]RESEARCH\b/i.test(synthesisSec);
const hasPlanArtifact = /(?:^|\s)plans\/[\w./-]+/.test(synthesisSec);
if (moreResearch) {
// Honest loop-back path: open questions REQUIRED, feasibility verdict
// explicitly NOT required — a substantiated MORE-RESEARCH is the honest
// outcome; demanding feasibility prose would teach padding.
const openQs = (synthesisSec.match(/\?\s*$/gm) || []).length + (synthesisSec.match(/\bopen question|\bunknown|\bunresolved/gi) || []).length;
if (openQs < 1) {
SYNTH_REJECTS.push(`- **MORE-RESEARCH declared but unsubstantiated**: name the specific open questions/unknowns that require more research (at least one '?', 'open question', or 'unknown').`);
}
if (!hasSufficiency) {
SYNTH_REJECTS.push(`- **MORE-RESEARCH qualification**: state what evidence you have, what is missing, and why the gap blocks a design decision (evidence-sufficiency statement).`);
}
} else {
// Proceed-to-plan path: full qualification — sufficiency + feasibility + kill test.
if (!hasSufficiency) {
SYNTH_REJECTS.push(`- **SYNTHESIS qualification**: no evidence-sufficiency statement. State explicitly whether OUTWARD evidence is sufficient to design from, and why (e.g. 'Evidence sufficient: <2-3 sources converge on the mechanism>' or 'Evidence insufficient: <what specifically is missing>').`);
}
const feasMarker = /\bfeasib|\bwon'?t work\b|\bwill work\b|\brisk\b|\bfailure mode\b|\bassumption\b|\brealistic/i;
const hasFeasibility = feasMarker.test(synthesisSec);
if (!hasFeasibility) {
SYNTH_REJECTS.push(`- **SYNTHESIS qualification**: no feasibility judgement. State the working mechanism, the load-bearing assumptions, and the failure modes — and judge realistically whether the synthesis WILL WORK. Optimism is not evidence.`);
}
// Kill test: a feasibility claim must name its cheapest disproof.
// The plan must carry it as an early frozen test — prose is not a prediction.
const hasKillTest = /\bkill test\b|\bdisprov|\bfalsif/i.test(synthesisSec);
if (hasFeasibility && !hasKillTest) {
SYNTH_REJECTS.push(`- **SYNTHESIS kill test**: name the cheapest observation that would DISPROVE the feasibility claim (a 'kill test:' line). For workflow-only changes the protected CI run may be the kill test — say so explicitly. A claim with no disproof is not a claim.`);
}
if (!hasPlanArtifact) {
SYNTH_REJECTS.push(`- **SYNTHESIS disposition**: must reference a concrete plans/ artifact (ready to implement) or declare **MORE-RESEARCH** with the specific open questions. Neither found.`);
}
}
// --- Trail of use: each OUTWARD source must carry an inline annotation
// (what was learned from it) on the same line — the verifiable trail.
const annotated = [...outwardUrls.entries()].filter(([, url]) => {
const line = outwardSec.split('\n').find(l => l.includes(url));
if (!line) return false;
const prose = line.replace(externalUrlRe, '').replace(/[-*`>\[\]()]/g, '').trim();
return prose.length >= 15;
});
if (outwardUrls.size >= 2 && annotated.length < 2) {
missing.push(`- **OUTWARD trail**: each source needs an inline note of what it taught (URL + ≥15 chars of what was learned, same line) — ${annotated.length}/${outwardUrls.size} annotated. A bare URL list is not a research trail.`);
}
// --- Live verification: fetch up to 5 sources; dead/hallucinated links fail.
// 2xx/3xx = real; 403/405/406/429 = real-but-bot-blocked (count as verified);
// 404/410/DNS/timeout (after 1 retry) = dead.
const toVerify = [...outwardUrls.values()].slice(0, 5);
const dead = [];
for (const u of toVerify) {
let ok = false;
for (let attempt = 0; attempt < 2 && !ok; attempt++) {
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 8000);
const r = await fetch(u, { redirect: 'follow', signal: ctl.signal, headers: { 'User-Agent': 'rune-research-gate/1.0 (source verification)' } });
clearTimeout(t);
ok = (r.status < 400) || [403, 405, 406, 429].includes(r.status);
if (!ok && [404, 410].includes(r.status)) break; // definitively dead, no retry
} catch (e) {
clearTimeout(t);
if (attempt === 1) { /* second failure — stays dead */ }
}
}
if (!ok) dead.push(u);
}
if (dead.length > 0) {
missing.push(`- **OUTWARD verification**: source(s) failed live fetch: ${dead.join(', ')} — dead or unreachable links cannot serve as research evidence.`);
}
const labels0 = (issue.labels || []).map(l => l.name);
if (!labels0.includes('research-pending')) {
core.info(`research-clear: #${issue.number} has no research-pending label — skip`);
return;
}
if (missing.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🚫 **Research round REJECTED — evidence gate.** All 4 phase headers present, but the evidence floor is not met:\n\n${missing.join('\n')}\n\nresearch-pending stays. Post the missing evidence (real sources fetched, real paths read) and the gate will re-run on your next comment.`,
});
core.info(`research-clear: evidence gate rejected #${issue.number}: ${missing.length} gap(s)`);
return;
}
// --- Honest loop-back: synthesis declared MORE-RESEARCH with substantiated
// open questions and all evidence floors met. This is a SUCCESS for honesty:
// research-pending stays, the named unknowns drive the next research pass.
// Bound: after 2 prior loop-backs on this issue, escalate to the owner
// instead of cycling silently — infinite MORE-RESEARCH is dithering.
if (moreResearch) {
let priorLoopBacks = 0;
try {
const comments = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, per_page: 100,
});
priorLoopBacks = comments.data.filter(c => (c.body || '').includes('honest loop-back accepted')).length;
} catch (e) { core.info('loop-back count fetch failed: ' + (e && e.message)); }
if (priorLoopBacks >= 2) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `⏸️ **Loop-back bound reached (${priorLoopBacks} prior MORE-RESEARCH cycles).** The synthesis again declares MORE-RESEARCH with substantiated open questions. Escalating to the owner rather than cycling silently: this issue needs a human decision — narrow the scope, accept a design under uncertainty, or split the unknowns into their own research issues.`,
});
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['blocked'],
}).catch(e => { if (e.status !== 404) throw e; });
core.info(`research-clear: loop-back bound reached on #${issue.number} — escalated with blocked label.`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🔁 **Synthesis not ready — honest loop-back accepted (cycle ${priorLoopBacks + 1}).** Evidence floors met (INWARD citations: ${inwardCites.size}, OUTWARD sources: ${outwardUrls.size}, trail annotated: ${annotated.length}, live-verified: ${outwardUrls.size - dead.length}), but the synthesis itself declares MORE-RESEARCH with substantiated open questions.\n\nresearch-pending stays. Next round must resolve the named unknowns — a later synthesis must either produce a plans/ artifact or identify NEW unknowns. Bound: 2 loop-backs, then escalation.`,
});
core.info(`research-clear: honest MORE-RESEARCH loop-back on #${issue.number} (cycle ${priorLoopBacks + 1}) — research-pending retained.`);
return;
}
// Clear research-pending, add in-progress
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, name: 'research-pending',
});
} catch (e) { if (e.status !== 404) throw e; }
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['in-progress'],
}).catch(e => { if (e.status !== 404) throw e; });
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🔬 **Research round verified — synthesis qualified.** 4 phases + evidence floor met (INWARD citations: ${inwardCites.size}, OUTWARD sources: ${outwardUrls.size}, trail annotated: ${annotated.length}, live-fetch verified: ${outwardUrls.size - dead.length}); synthesis declared evidence ${hasSufficiency ? 'sufficiency statement ✓' : '✓'} + feasibility judgement ✓ and committed to a plans/ artifact.\n\n▶️ **in-progress applied** — Rune is now working on this issue. The pipeline will notify at each transition.`,
});
core.info(`research-clear: #${issue.number} research verified with evidence, label cleared.`);
# INCOMPLETE CATCHER: detect branches with commits but no PR, belonging to
# in-progress issues — auto-create draft PR with incomplete label + ledger.
# Runs on the hourly sweep (piggybacks on existing schedule trigger).
incomplete-catcher:
runs-on: rune-ci-desktop
steps:
- name: Detect abandoned in-progress work and create draft PRs
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// Find open issues with in-progress label
const issues = [];
for (let page = 1; ; page++) {
const res = await github.rest.issues.listForRepo({
owner, repo, state: 'open', labels: 'in-progress', per_page: 50, page,
});
issues.push(...res.data.filter(i => !i.pull_request));
if (res.data.length < 50) break;
}
if (issues.length === 0) { core.info('No in-progress issues.'); return; }
// Find open branches
const branches = await github.rest.repos.listBranches({ owner, repo, per_page: 100 });
const branchNames = branches.data.map(b => b.name);
// Find existing PRs
const prs = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
const prHeads = new Set(prs.data.map(p => p.head.ref));
for (const issue of issues) {
// Look for a branch matching the issue pattern
const branch = branchNames.find(b => b.includes(String(issue.number)) || b === issue.head_branch);
if (!branch || prHeads.has(branch)) continue;
// Branch exists, no PR yet — create draft PR with incomplete label
try {
const pr = await github.rest.pulls.create({
owner, repo,
title: `[INCOMPLETE] ${issue.title}`,
head: branch, base: 'master', draft: true,
body: [
'## Incomplete Work Ledger',
'',
`**Issue:** #${issue.number}`,
`**Branch:** ${branch}`,
`**Status:** Work in progress, paused mid-implementation.`,
'',
'### What exists',
'- See commits on this branch',
'',
'### What remains',
'- Resume implementation from where it left off',
'- Run gates when complete',
'',
'### Resume instructions',
'1. Check out this branch',
'2. Read the plan file in the worktree',
'3. Continue from the last commit',
'4. Mark ready when implementation is complete',
].join('\n'),
});
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.data.number, labels: ['incomplete'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: `📋 **Incomplete work captured** — draft PR #${pr.data.number} created from branch \`${branch}\`. Resume from there when ready.`,
});
core.info(`Created draft PR #${pr.data.number} for issue #${issue.number}`);
} catch (e) {
if (e.status !== 422) throw e; // 422 = PR already exists
}
}
+15
View File
@@ -17,6 +17,12 @@ Only use `git commit --no-verify` in a genuine emergency: it bypasses the hooks
- Plans are markdown, one per build (in ./plans)
- Top-level docs are markdown or yaml references (in ./docs/*)
- The known giant stays visible — file-size debt is tracked, not silent (in ./src/rune/*)
- The bug-fix discharge contract is a governed doc — it exists, is markdown, and changes are deliberate (in ./docs/bugfix-contract.md)
- The discharge checker ships with the repo — red-first enforcement is structural (in ./scripts)
- Discharge checker and its CI wiring change together
- Every workflow is registered in the pipeline registry — new pipeline machinery ships with documentation (in ./.gitea/workflows/*.yml)
- Every TUI surface is registered — no silent UI growth (in ./src/rune/*tui*.py)
- New rune modules ship with tests or a canary — no ungoverned code surfaces (in ./src/rune/*.py)
### Behaviours
@@ -33,3 +39,12 @@ Need a check that genuinely doesn't exist? `henxels create-new-statement <name>`
other repos, not tied to this one — contribute it upstream with `henxels contribute`.
We're in the agentic era: send a ready-to-merge PR instead of opening an issue.
<!-- henxels:end -->
## Bug fixes (binding)
Every bug found — by gates, CI, canaries, production, or humans — is fixed per
`docs/bugfix-contract.md`: red test FIRST (reproduces the cause), root-cause
fix, full pipeline re-entry (suite + ratchet + ruff + canaries + henxels;
gates for anything beyond a mechanical minor), regression tests frozen forever,
discharge recorded finding → test → commit. Symptom patches are rejected at
review. To escape: edit `henxels.yaml` or this file — visibly.
+4 -4
View File
@@ -103,10 +103,10 @@
"evidence": "REMOVED by P0-04 (#20): _run_warm_router is a documented no-op; the ContextBroker (#19) owns all model-facing context. See docs/pipeline-removal.md."
},
{
"path": "src/rune/attention_capsule.py:96-163",
"role": "Bounded working-set candidate",
"disposition": "dormant-until-dependency",
"evidence": "Creates a bounded capsule but does not own prompt construction."
"path": "src/rune/attention_capsule.py",
"role": "AttentionCapsule — the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
"disposition": "retain-authority",
"evidence": "P1-02 (#25): the capsule IS the product the model receives — the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
},
{
"path": "src/rune/inference_trace.py",
+86
View File
@@ -0,0 +1,86 @@
# Bug-fix contract
_Binding on every agent, session, and pipeline stage in this repository. The
henxel mirror of this contract lives in `henxels.yaml` ("bugs discharged
red-first"); the enforcement hooks live in `scripts/check_discharge_contract.py`
and `.gitea/workflows/pipeline-gate.yml`. Escapes from this contract are made
by editing those files — visibly — never by silent deviation._
## The contract
When a **bug is found** — by a gate, a reviewer, CI, a canary, production, or a
human — the fix MUST follow this cycle before it may merge:
1. **Red first.** A failing test that reproduces the bug is written BEFORE the
fix. The test must fail for the *bug's cause*, not for an unrelated reason.
A fix without a red test is a symptom patch and is rejected at review.
2. **Root cause, not symptom.** The fix addresses the cause. If the reporter's
diagnosis is wrong, the discharge says so and names the real cause.
3. **Full pipeline re-entry.** The fix is treated as a NEW SUBMISSION: full
suite, pyright ratchet (not increased), ruff, canaries, henxels, and the
spec + quality gates for anything beyond a mechanical minor. No
"hotfix-skips-gates" path exists.
4. **The regression test is permanent.** Tests written for a discharged bug are
frozen — they may be refactored, never weakened or deleted. The suite can
only grow in assertion strength over time.
5. **Recorded.** The fix's PR (or plan tail) maps every finding → the red test
→ the commit. A discharge record with holes bounces at review.
## Scope and proportionality
- **Gate findings (spec/quality):** every BLOCKER/MAJOR gets the full cycle;
MINORs get at minimum a red test OR an explicit reviewer-approved waiver
recorded in the plan tail.
- **Production failures:** the fix follows this contract AND, where the failing
payload is preservable, #157's fixture pipeline (real failure → sanitised
fixture → permanent regression test).
- **CI/workflow bugs:** the red test is the workflow-contract assertion
(parsed-YAML step/step-logic assertions, as in `test_workflow_script_contract.py`)
— workflows are code; their bugs get tests too.
- **Trivial/no-test-class bugs** (typos in comments, log strings): documented
exemption list in this file. PR-only changes to those strings skip the red test.
## Why (the failure class this kills)
LLM-authored changes share wrong assumptions between implementation, tests, and
docs. A symptom patch shares the reporter's wrong assumption with the fix. The
red-first test is the cheapest known breaker of both: it forces the fixer to
state the bug's cause in executable form before claiming to have removed it.
## Enforcement
- `scripts/check_discharge_contract.py`: verifies a PR that touches files named
in a `Discharge:` trailer block carries at least one test-hunk per finding
id (structural check — PR description convention: `Discharge: <id> -> <test file>`).
- Gate reviewers are instructed (dispatch templates, issue #151) to reject
symptom patches: "what test failed red for this bug?" is a mandatory
reviewer question for every fix PR.
- `henxels.yaml` carries the tracked one-line mirror so the contract is part
of the repo's auditable contract surface.
## Binding refinements (pipeline doctrine, 2026-09-07)
6. **Soak-before-close for intermittent defects.** A flaky, timing, or
event-delivery bug cannot be proven fixed by one green run. The fix merges
through the normal pipeline, but the issue CLOSES only on soak evidence: a
stated criterion (e.g. "N consecutive green runs" or "48h without
recurrence") plus the observations meeting it. The closing comment carries
the soak record. Merge ≠ closure for this class.
7. **Escape accountability.** When a bug reaches production (escaped every
gate), the discharge must answer: _which gate should have caught this, and
why didn't it?_ The answer becomes a gate improvement — a new contract
assertion, a checker rule, or a documented gate limit. Gate hardening is a
first-class output of bug fixing, not an afterthought.
8. **Fresh-context root-cause verification.** The red test proves the fixer
understood _a_ cause. For BLOCKER/MAJOR fixes, a fresh-context reviewer
additionally asks: does this test fail on the pre-fix code _for the right
reason_? (Same doctrine as the black-box test author: the person who wrote
the fix does not certify its diagnosis.)
9. **Déjà-vu detection.** Before writing the red test, search the suite and
closed issues for the same failure class. A second occurrence of a class
means the first fix treated a symptom — the discharge must then cover the
class, not the instance.
10. **Fixture promotion.** When the red test needs a complex payload, it goes
through #157's sanitised-fixture pipeline (real failure → secrets stripped
→ `tests/fixtures/production/`) instead of a hand-crafted approximation.
Real failures are the best regression tests.
+67
View File
@@ -0,0 +1,67 @@
# Capsule Fidelity (P1-02, #25)
The AttentionCapsule is the **product the model receives** — not a
side-report. This document states the invariant, the mechanism, and how to
verify it.
## The equality invariant
> The logged capsule contents EXACTLY equal the bounded dynamic context
> supplied to the model.
Concretely, three renderings of one turn must be identical:
1. `capsule.to_messages()` — re-derived from the admitted-block ledger,
2. the message list passed to `LocalToolRuntime.run(...)` — captured
pre-run via `capsule.capture_messages()` → `capsule.sent_messages`,
3. `InferenceTrace.capsule_messages` — the durable JSONL log.
Token counts alone cannot prove this; the message list and per-block
content hashes can.
## Mechanism
- **Assembly:** `ContextBroker.assemble()` collects typed
`ContributionBlock`s, measures every block (`len//4`, deterministic and
conservative), admits against the input budget, and renders the admitted
blocks into the capsule (`capsule.blocks`).
- **Payload derivation:** `AttentionCapsule.to_messages()` re-derives the
chat message list from `capsule.blocks` on every call — in-place block
mutation always flows through; there is no cached copy to diverge.
- **Capture:** `RuneOperator._execute_on_model` calls
`capsule.capture_messages(capsule.to_messages())` immediately before
`runtime.run`. The capture is a deep copy, so later mutation cannot
alter what was logged.
- **Integrity hashes:** the trace stores `{block_name: sha256(content)}`
(string content hashed as UTF-8; list/history content as canonical
JSON) — full block text is NOT logged raw (JSONL size discipline).
- **Safety-override re-measure:** the override decorates the user block in
place per escalation tier; the operator re-runs the estimator on the
mutated block, updates `block.tokens`, the broker trace's per-block
entry, and `capsule.token_count`. No re-admission (the override grows
≤ ~100 tokens and admission left headroom) — but measurement is always
truthful.
- **num_ctx propagation:** the runtime receives
`num_ctx = capsule.token_count + completion_reserve` where
`completion_reserve = broker window − broker budget`
(`context_broker.default_budget` reserves the completion room). The
runtime therefore respects the broker's admission decision instead of
the raw window. #28 owns the broader model-aware budget work.
## Verification
- Frozen tests: `tests/test_p1_02_capsule_authority.py` — asserts
`trace.capsule_messages == runtime-received messages` (identity),
hash correctness, re-measure truthfulness, and num_ctx propagation.
- Canary: `python3 src/rune/canary_p1_02.py` prints
`P1-02 CAPSULE FIDELITY CANARY PASSED` (registered in
`.gitea/workflows/ci.yml`'s canary loop).
## Retired legacy path
`attention_capsule.create_capsule` and `AttentionCapsule.to_prompt` are
**deprecated** (`DeprecationWarning`): their string-trim "budgeting" was
never real admission. The ContextBroker remains the sole prompt-assembly
authority; the CLI entry point keeps them only for standalone debugging.
`ContributionBlock.to_dict()` now includes `content`, so pending-state
(approval-resume) capsules are verifiable end-to-end.
+13 -2
View File
@@ -17,11 +17,11 @@ is gitignored, P0-05 M6) — never committed. Consumers: #25, #28, #29.
Read back with `rune.inference_trace.read_traces()` or
`python3 -m rune.inference_trace [n]` (tail n records).
## Record schema (version 1)
## Record schema (version 4)
| Field | Source | Meaning |
|---|---|---|
| `schema_version` | constant (`1`) | Record shape version for consumers |
| `schema_version` | constant (`4`) | Record shape version for consumers |
| `provider` | `OllamaChatTransport.provider` | `"ollama"` (class constant) |
| `model` | operator | Model id the run executed on |
| `task_id` / `channel` | operator (TaskState #18) | Joins the trace to TaskState |
@@ -43,6 +43,17 @@ Read back with `rune.inference_trace.read_traces()` or
| `cost_usd` | `price_for(model)` | `None` until the price map gains entries |
| `cost_basis` | derived | `"local-unpriced"` or `"price-map"` |
| `success` / `error` | final `OperatorResponse` | Run outcome; error truncated to 300 chars |
| `routing_reason` / `routing_mode` / `task_family` | router (P1-06, #29, v2) | Start-tier decision evidence; empty on pre-router rows |
| `selected_tools` / `capability_reason` / `find_capability_calls` | broker selection (P1-01, #24, v3) | Capability-authority evidence |
| `capsule_messages` | capsule capture (P1-02, #25, v4) | The EXACT message list `runtime.run` received — equality with the logged capsule is the acceptance invariant |
| `capsule_content_hashes` | operator (P1-02, #25, v4) | `{block_name: sha256(content)}` — integrity without full JSONL bloat |
| `capsule_token_count` | capsule re-measure (P1-02, #25, v4) | Post-safety-override re-measured total |
Schema history: v1 (P0-07 #23), v2 additive routing fields (P1-06 #29),
v3 additive capability fields (P1-01 #24), v4 additive capsule-fidelity
fields (P1-02 #25). All bumps are additive with defaults — older rows
without the new keys still parse (`read_traces` consumers must tolerate
missing keys).
## Authority and single-source rule
+35
View File
@@ -0,0 +1,35 @@
# Module coverage debt ledger
_Tracked, not silent — the repo's doctrine for known debt (same pattern as the
file-size giants in `henxels.yaml`). The `new_rune_modules_have_canary_or_test_coverage`
henxel fails any `src/rune/*.py` module with no test reference and no canary._
**Rule: this list only shrinks.** Every PR that adds a test or canary for a
module removes it here. New modules can never be added — they ship governed.
When the list is empty, this file records the fact and the henxel needs no
escape.
## Mechanism
The henxel reads this file: a module listed under `## Debt` is *tracked debt*
(warning-level in the ledger, visible here) instead of a contract failure. The
distinction matters — nothing below is approved as "fine", it is *known, owned,
and scheduled for elimination* via the P2 consolidation tranche (#125, #41).
## Debt (58 modules as of 2026-09-07, baseline master 2975d76)
abstraction, audit_ledger, claim_audit, cognitive_budgeter, complexity_scorer,
connection_pool, context_pressure, cron_engine, curator, dependency_tracker,
drift_cache, drift_compression, drift_core, drift_observability,
elastic_cognition, goal, graph_memory, growth_delta, hook_engine,
identity_anchors, memory_consolidation, memory_env, message_queue, model_connect,
model_registry, night_mode, outcome_ledger, parallel_processor, persona_definition,
persona_gate, progressive_escalation, provider_budget, provider_registry,
query_memory, recall_router, response_adapter, rune_graphic, rune_profiler,
rune_spinner, salience_gate, self_improve, shard_manager, standardized_providers,
subconscious_pass, system_registry, task_gate, task_persist, tiered_executor,
tier_manager, time_awareness, token_optimizer, tui_validator, verification_layer,
voicebox, voice_hybrid, voice_injector, voice_sync, warm_neuron_store, work_manager
_Henxel escape: add the module name to this list only when the PR that touches
the module cannot also carry its first test — and say why in the PR body._
+25
View File
@@ -0,0 +1,25 @@
# Pipeline registry
_Every workflow in `.gitea/workflows/` is registered here — trigger, purpose,
failure mode. Governed by the `workflows_are_governed` henxel: a new workflow
that isn't registered fails the contract. Add the registry entry IN THE SAME
PR as the new workflow._
| Workflow | Trigger | Purpose | Failure mode |
|---|---|---|---|
| `ci.yml` | PR + push to master | lint, unit tests, canaries, best-practices, documentation, type-check (ratchet), gitea-offline, required-checks aggregation | Required checks fail → protected merge blocked |
| `pipeline-gate.yml` | issue events, comment created, schedule (hourly), workflow_run | gate (deps/research-pending), teach (next-issue), research-clear (evidence floor: citations, live-fetched sources, trail, synthesis qualification), incomplete-catcher (draft PRs for abandoned work) | Rejection comments name gaps; labels stay |
| `auto-version.yml` | pull_request:closed + push to master | semver from PR title (authoritative) / push-triggered convergence safety net (scripts/version_convergence.py) → bump PR → release | Safety net reconciles slipped events; drift flagged by maintenance |
| `maintenance.yml` | schedule | drift reconciliation (release version vs tags), housekeeping | Files advisory "release drift" issue, deduped |
| `merge-robot.yml` | schedule (hourly) + CI completion | merge-on-green: PRs with success status + mergeable merge via API | 405 (protection/stale) logged and skipped — **known silent-failure defect under discharge (bug-fix contract)** |
| `auto-label.yml` | issue events | priority gate, label transitions | Gate comments on unlabelled issues |
| `auto-backup.yml` | schedule | repo backup | Journal alert on failure |
| `auto-update-gitea.yml` | schedule | Gitea self-update | Binary-only rollback risk (T1.1 audit finding) |
## Adding a workflow
1. Create `.gitea/workflows/<name>.yml`.
2. Add a row here (trigger / purpose / failure mode) in the same PR.
3. If the workflow has a canary-relevant side effect, register the canary in
`ci.yml` (the `ci_runs_all_canaries` henxel enforces it).
4. Verify: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/<name>.yml'))"`.
+20
View File
@@ -0,0 +1,20 @@
# TUI registry
_Every TUI surface in `src/rune/` is registered here — what it renders, who
owns it. Governed by the `tui_panels_are_governed` henxel: a new TUI surface
that isn't registered fails the contract. Add the entry IN THE SAME PR as the
new surface._
| Surface | Renders | Notes |
|---|---|---|
| `rune_tui.py` | The main Textual application — chat pane, work pane, status bar (version label + refresh), footer, dialogs | The known giant (file-size debt tracked in henxels); composition root for all UI |
| `tui_validator.py` | Import-allowlist validation for TUI code | Drift defect tracked in #139 (allowlist vs `__future__`/relative imports) |
## Adding a TUI surface
1. Create the module under `src/rune/`.
2. Add a row here (renders / notes) in the same PR.
3. Ship a test or canary (the `new_rune_modules_have_canary_or_test_coverage`
henxel enforces coverage).
4. Decomposition of `rune_tui.py` along the authority map is tracked in #125 —
new panels should target the decomposed structure, not grow the giant.
+84
View File
@@ -0,0 +1,84 @@
# Umbrella system → henxels contracts + retroactive governance — design for confirmation
## Part 1: Retroactive application (ready to execute now)
The survey found six ungoverned surfaces. Proposed henxels rules, in the tool's
real schema, each following an existing pattern:
| Surface | Finding | Proposed henxel (keyed, real schema) |
|---|---|---|
| `shared/*.py` (24 files) | ~100% near-duplicates of `src/rune/` modules (the mtime-copy sync is the cause) | `changed_with`: when `shared/*.py` changes, `src/rune/` mirror must change too — **or better (Part 2) the dupes die** |
| `research/**` (63 files, 16 dirs) | No index; unlinked pages | `referenced_in: docs/research-index.md` + `links_resolve` — orphan research is tracked |
| `memory/` (runtime state tracked in git!) | `journal/events.jsonl` + runtime JSON in the repo — runtime data in version control | `forbidden_files: [./memory/journal/events.jsonl, ./memory/index/*.json]` after a one-time extraction (runtime state belongs to the deployed agent, not the codebase) |
| `backups/*.bak` (3 files) | Build backups committed | `forbidden_files: [./backups/**]` (git history IS the backup) |
| `specs/`, `reports/` | Type-ungoverned | `allowed_filetypes: [.md]` (matches the plans rule) |
| `survey/` (7 files) | Ungoverned | fold into research indexing or `allowed_filetypes: [.md, .yaml]` |
All are mechanical; the `memory/` extraction is the only one touching runtime —
I'd do it in the same PR with the agent's own journal-preservation discipline
(backup branch first).
## Part 2: Umbrella system → contracts (needs your confirmation on shape)
### What the umbrella system is (verified)
`shared/umbrella_update.py`: a hand-maintained manifest (`UMBRELLAS` dict) of six
functional umbrellas — **memory, governance, identity, growth, perception,
infrastructure** — each listing shared/scripts/canaries components plus a
`depends_on` DAG (memory→governance→identity; growth→memory+governance). Purpose:
say "update memory" and the system knows the affected files, runs the umbrella's
canaries, syncs shared→deploy targets by mtime, and commits.
### What's wrong (verified defects)
1. **Manifest drift**: `memory_selector.py` and every post-manifest module is in
NO umbrella; it references canaries (`canary_unbounded.py`,
`canary_drift_wiring.py`) that don't exist in the repo.
2. **Two-sources-of-truth by design**: `sync_shared_to_scripts()` mtime-copies
`shared/` → `~/.config/opencode/scripts/` + the old TUI dir. This is why
henxels flags ~100% duplicates. The repo and the harness both "own" copies.
3. **Hardcoded harness paths** (`~/.config/opencode/...`) — not repo-portable,
dead on the runner.
### Proposed design: three contracts, one removal
**Contract A — `umbrella_manifest_is_complete`** (new custom statement):
every `src/rune/*.py` and `shared/*.py` module must appear in the umbrella
manifest (or the coverage-debt ledger). New modules force an umbrella decision —
the manifest can never silently drift again. This is the umbrella's real
invariant: *the system knows what needs updating* — enforced.
**Contract B — `umbrella_canaries_exist`** (new custom statement): every canary
referenced by the manifest must exist in `src/rune/` and be CI-registered
(composes with the existing `ci_runs_all_canaries`). Kills defect 1's phantom
references.
**Contract C — `umbrella_dag_acyclic_and_declared`** (new custom statement):
parse `UMBRELLAS`, verify `depends_on` is a DAG (no cycles), every dependency
target exists. Cheap, structural, protects the reflex's core logic.
**The removal (your call):** the mtime-copy sync. Two honest options:
- **Option 1 (recommended): repo-as-source-of-truth.** The repo's `src/rune/` +
`shared/` become the ONLY governed copies. The deployed harness gets its
scripts from the pinned checkout (the update flow already force-checks-out
gitea/master). `sync_shared_to_scripts()` is retired; the
near-duplicate `shared/` files either become true imports of `src/rune` or
move to the harness (outside the repo). Contract A then governs ONE tree.
Cost: the harness-side scripts dir stops being independently editable —
which is the point.
- **Option 2: keep dual-location, gate the sync.** Keep the copies but add a
`must_be_in_sync`-style contract: shared/ and its mirrors must be
content-identical at commit time (henxels `changed_with` + a checksum
statement). Cost: permanent duplication machinery; drift only detectable,
never prevented.
### Sequencing
1. Retroactive henxels (Part 1) — one PR, mechanical.
2. Contracts A+B+C + the manifest modernized in-repo (move `UMBRELLAS` to
`config/umbrellas.json`, sourced by the script) — second PR.
3. Option 1's sync retirement — its own PR, after you confirm.
**Confirm**: Option 1 or 2 for the sync? And does the `memory/` runtime-state
extraction have your blessing (it touches the live journal)?
+25
View File
@@ -32,3 +32,28 @@ henxels:
- ./src/rune/tool_executor.py
- ./src/rune/warm_router.py
max_lines: 1200
- henxel: "The bug-fix discharge contract is a governed doc — it exists, is markdown, and changes are deliberate"
in: ./docs/bugfix-contract.md
allowed_filetypes: [.md]
- henxel: "The discharge checker ships with the repo — red-first enforcement is structural"
in: ./scripts
required_files: [check_discharge_contract.py]
- henxel: "Discharge checker and its CI wiring change together"
changed_with:
when: ./scripts/check_discharge_contract.py
expect: ./.gitea/workflows/pipeline-gate.yml
- henxel: "Every workflow is registered in the pipeline registry — new pipeline machinery ships with documentation"
in: ./.gitea/workflows/*.yml
workflows_are_governed: true
- henxel: "Every TUI surface is registered — no silent UI growth"
in: ./src/rune/*tui*.py
tui_panels_are_governed: true
- henxel: "New rune modules ship with tests or a canary — no ungoverned code surfaces"
in: ./src/rune/*.py
new_rune_modules_have_canary_or_test_coverage: true
+84
View File
@@ -15,3 +15,87 @@ def ci_runs_all_canaries(param, file, root):
if Path(file).name in ci.read_text():
return None
return f"{file} is missing from .gitea/workflows/ci.yml — register it in the canary job's for-loop list"
# --- Pipeline-expansion contracts (issue #204 doctrine): when a new process,
# feature, workflow, or backend surface is added, a governing contract must
# exist for it. These statements make "ungoverned surface" a check failure.
@statement(
"workflows_are_governed",
help="every .gitea/workflows/*.yml is referenced by docs/pipeline-registry.md — new pipeline machinery ships with documentation",
)
def workflows_are_governed(param, file, root):
if not file.startswith(".gitea/workflows/") or not file.endswith(".yml"):
return None
registry = root / "docs" / "pipeline-registry.md"
if not registry.exists():
return f"docs/pipeline-registry.md is missing — create it and register {file} (every workflow documents trigger/purpose/owner)"
if Path(file).name not in registry.read_text():
return f"{file} is not registered in docs/pipeline-registry.md — new pipeline processes ship with a registry entry (trigger, purpose, failure mode)"
return None
@statement(
"tui_panels_are_governed",
help="new TUI surfaces (src/rune/*tui*.py panels/widgets) are referenced in docs/tui-registry.md — no silent UI growth",
)
def tui_panels_are_governed(param, file, root):
if Path(file).stem.startswith("canary_"):
return None
if "tui" not in Path(file).name.lower():
return None
registry = root / "docs" / "tui-registry.md"
if not registry.exists():
return f"docs/tui-registry.md is missing — create it and register {file} (every TUI surface documents what it renders and who owns it)"
if Path(file).name in registry.read_text():
return None
# registry exists but this file is new/unregistered — check for a known-exempt core list
core = {"rune_tui.py"}
if Path(file).name in core:
return None
return f"{Path(file).name} is not registered in docs/tui-registry.md — new TUI surfaces ship with a registry entry"
def _tests_corpus(root) -> str:
tests_dir = root / "tests"
if not tests_dir.exists():
return ""
parts = []
for tf in sorted(tests_dir.glob("test_*.py")):
try:
parts.append(tf.read_text(errors="replace"))
except OSError:
continue
return "\n".join(parts)
@statement(
"new_rune_modules_have_canary_or_test_coverage",
help="every src/rune/*.py module is referenced by at least one test file or has a canary — no ungoverned code surfaces",
)
def new_rune_modules_have_canary_or_test_coverage(param, file, root):
if not file.startswith("src/rune/") or not file.endswith(".py"):
return None
name = Path(file).stem
if name.startswith(("canary_", "test_", "__")):
return None
tests_dir = root / "tests"
if (tests_dir / f"test_{name}.py").exists():
return None
canary = root / "src" / "rune" / f"canary_{name}.py"
if canary.exists():
return None
corpus = _tests_corpus(root)
if name in corpus:
return None
debt = root / "docs" / "module-coverage-debt.md"
if debt.exists():
debt_text = debt.read_text(errors="replace")
if name in debt_text:
return None # tracked debt — visible in docs/module-coverage-debt.md (list only shrinks)
return (
f"{name} is referenced by no test file and has no canary — new modules ship with coverage "
f"(a test importing/exercising it, or a canary) per docs/bugfix-contract.md; "
f"legacy debt goes in docs/module-coverage-debt.md (tracked, not silent)"
)
+127
View File
@@ -0,0 +1,127 @@
# P1-02 AttentionCapsule → Real Model Product — Implementation Plan (#25)
> **For agentic workers:** execution contract — FROZEN TESTS ARE THE SPEC. Implement verbatim; record every deviation in the plan tail. TDD red → green. Do not create/complete tasks; controller owns task state.
**Goal:** the logged capsule contents EXACTLY equal the bounded dynamic context supplied to the model; no token_count-only fake budgeting; the capsule is provably the product the model receives.
**Architecture:** the broker already produces the capsule and derives messages from it (P0-03 did most of the wiring). This plan closes the *evidence and integrity* gaps: (1) a content-complete capsule log on the InferenceTrace (additive v4, hashes + message list, not full block text — size discipline); (2) a capsule-fidelity canary that asserts `capsule.to_messages()` == the messages passed to `runtime.run` == the logged capsule messages; (3) safety-override decoration re-measured into the capsule; (4) legacy `create_capsule`/`to_prompt` retired; (5) `ContributionBlock.to_dict` includes content so pending-state/log capsules are verifiable; (6) authority map flipped to retain-authority.
**Tech stack:** stdlib + existing house modules. No new services, no parallel assembly.
## Global Constraints
- Acceptance criterion (from #25): logged capsule contents EXACTLY equal the bounded dynamic context supplied to model. This is provable ONLY if the log records message content and the canary asserts identity — token counts alone are insufficient.
- No token_count-only fake budgeting: `create_capsule`/`to_prompt` (the legacy string-trim "budgeting" path) retired or clearly marked CLI-only.
- Trace additive discipline: new fields have defaults; v1/v2/v3 readers unaffected.
- Degrade-not-crash: capsule logging must never fail the turn (P0-06).
- Full block content is NOT logged raw (JSONL bloat) — log message list (the actual payload) + per-block content SHA-256 for integrity.
- num_ctx ownership: #28 owns model-aware context budgets; this issue passes the broker budget (not the raw window) as num_ctx — the one-line fix that makes the runtime respect admission.
- Safety-override decorated user block MUST be re-measured so `capsule.token_count` stays truthful post-decoration.
- TDD; ruff check + format on touched/added files; canary registered in ci.yml same commit.
## Design Decisions (settled)
1. **Capsule log location:** InferenceTrace additive v4 fields — `capsule_messages: list[dict] = []` (the exact messages sent), `capsule_content_hashes: dict[str,str] = {}` (block name → SHA-256 of content), `capsule_token_count: int = 0` (post-decoration re-measured total). No separate JSONL file; rotation already handled.
2. **Equality invariant:** `capsule.to_messages()` result is captured BEFORE `runtime.run` and stored on the trace; a frozen test asserts trace.capsule_messages == the list passed to runtime.run (identity, not re-measure). The canary asserts the same against a scripted runtime.
3. **Safety-override re-measure:** after override mutates the user block content, re-run `estimate_tokens` on the mutated block, update `block.tokens` + `capsule.token_count` + the trace. No re-admission (override grows ≤ ~100 tokens; admission already left headroom).
4. **num_ctx:** pass `capsule.token_count + completion_reserve` where completion_reserve = the broker's reserved completion room (window − budget). One-line change; #28 owns the broader model-aware budget work.
5. **Runtime fallback tool prompt:** formally exempt — `_with_fallback_prompt` only fires for non-native models, and its cost is outside broker scope. Add a comment + log-only accounting note. Full integration is a #28/#34 follow-up.
6. **Legacy retirement:** `create_capsule` and `to_prompt` marked deprecated (docstring + `warnings.warn(DeprecationWarning)`), zero live callers verified by test. `to_dict` gains `content` field. Authority map: `attention_capsule.py` → retain-authority under ContextBroker.
## Tasks
### Task 1 — ContributionBlock.to_dict includes content + AttentionCapsule message capture
**Files:** modify `src/rune/context_broker.py` (to_dict + capsule), `src/rune/attention_capsule.py` (deprecation), test `tests/test_p1_02_capsule_authority.py`.
**Interfaces:**
- Produces: `ContributionBlock.to_dict()` includes `"content"`; `AttentionCapsule.capture_messages()` → stores the exact list on `capsule.sent_messages` (not a dataclass field — set post-hoc like the existing dynamic attrs); `create_capsule`/`to_prompt` emit DeprecationWarning.
- [ ] **Step 1: failing tests** — to_dict includes content matching the block's content; to_dict without content key in old fixture still loads (back-compat); capture_messages stores the list; to_messages re-derivation equals captured list for the same capsule; create_capsule raises DeprecationWarning.
- [ ] **Step 2: run → red**
- [ ] **Step 3: implement**
- [ ] **Step 4: green**
- [ ] **Step 5: commit** `feat(p1-02): capsule content logging + message capture + legacy deprecation (#25)`
### Task 2 — Safety-override re-measure + num_ctx budget fix
**Files:** modify `src/rune/rune_operator.py`, test file.
- [ ] **Step 1: failing tests** — after safety-override mutates the user block, `capsule.token_count` reflects the new estimate (not stale); trace `per_block_tokens["user"]` matches; `num_ctx` passed to runtime.run equals `capsule.token_count + completion_reserve` (not the raw window).
- [ ] **Step 2: red** → **Step 3: implement** → **Step 4: green**
- [ ] **Step 5: commit** `feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25)`
### Task 3 — Trace v4 capsule fields + operator logging
**Files:** modify `src/rune/inference_trace.py` (v4 fields), `src/rune/rune_operator.py` (log messages + hashes into trace), test file.
**Interfaces:** Consumes Task 1. Produces: `InferenceTrace.capsule_messages`, `capsule_content_hashes`, `capsule_token_count` (additive, defaults preserve v1–v3 readers).
- [ ] **Step 1: failing tests** — trace record carries capsule_messages equal to the messages the scripted runtime received; content hashes present and correct; old trace rows without v4 fields still parse.
- [ ] **Step 2: red** → **Step 3: implement** → **Step 4: green**; full suite regression (baseline 588+84).
- [ ] **Step 5: commit** `feat(p1-02): trace v4 capsule fidelity fields (#25)`
### Task 4 — Authority map + docs
**Files:** `config/v1_authority_map.json` (attention_capsule → retain-authority, evidence), `tests/test_v1_authority_map.py` (if pinned), `docs/capsule-fidelity.md` (create: what the capsule is, the equality invariant, how to verify, num_ctx propagation).
- [ ] Steps: map update → docs → commit `feat(p1-02): capsule authority map + docs (#25)`
### Task 5 — Canary + CI registration
**Files:** create `src/rune/canary_p1_02.py`; modify `.gitea/workflows/ci.yml` same commit.
- [ ] Canary: scripted operator run asserts (1) trace.capsule_messages == runtime-received messages (exact list identity), (2) capsule_token_count truthful post-decoration, (3) num_ctx == budget not window, (4) content hashes match block contents, (5) no DeprecationWarning suppressed. Prints `P1-02 CAPSULE FIDELITY CANARY PASSED`.
- [ ] Full suite; ruff; henxels; commit `feat(p1-02): canary + CI registration (#25)`
## Acceptance (from #25)
- Logged capsule contents EXACTLY equal the bounded dynamic context supplied to model — proven by trace v4 `capsule_messages` == `runtime.run` messages, asserted in frozen test + canary.
- No token_count-only fake budgeting — `create_capsule`/`to_prompt` deprecated, zero live callers verified; admission remains the real budget enforcement.
## Pipeline requirements checklist
- [x] TDD red→green (frozen tests per task)
- [x] ruff check + format on touched/added files
- [ ] Spec gate PASS; Quality gate Approved (discharges if needed)
- [x] Full-suite regression green (baseline 588+84 → 604+84, +16 new)
- [x] Canary green + registered same commit; henxels exit 0
- [ ] Closed with formatted comment + PR refs
## Completion Record (P1-02 implementer, 2026-09-06)
**Status:** all 5 tasks complete. Branch `p1-02-capsule-authority`, head `96e42ae`.
**Commits:**
1. `d0550b6` feat(p1-02): capsule content logging + message capture + legacy deprecation (#25)
2. `ec0cf7a` feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25)
3. `8ff6a83` feat(p1-02): trace v4 capsule fidelity fields (#25)
4. `90bcd59` feat(p1-02): capsule authority map + docs (#25)
5. `96e42ae` feat(p1-02): canary + CI registration (#25)
**Final counts:** 604 passed + 84 subtests (baseline 588+84; +16 new frozen tests, 0 regressions).
Canary: `P1-02 CAPSULE FIDELITY CANARY PASSED`. henxels exit 0 (21 pre-existing warnings).
**Red evidence** (initial run of tests/test_p1_02_capsule_authority.py, 14 failed / 2 passed):
`test_contribution_block_to_dict_includes_content`, `test_capture_messages_stores_the_exact_list`,
`test_create_capsule_emits_deprecation_warning`, `test_safety_override_reremeasures_capsule_token_count`,
`test_num_ctx_is_budget_plus_completion_reserve_not_window`, `test_inference_trace_schema_v4_fields`,
`test_operator_trace_record_carries_capsule_messages` (+7 more) — all red before their task's implementation, green after.
**Deviations from plan:**
1. **Schema v4 bump touched pinned literals:** `tests/test_p1_01_capability_authority.py` (3 assertions) and canaries
`canary_p1_01.py` / `canary_p1_06.py` pinned `schema_version == 3`. Updated those pins to 4 in the same Task-3 commit —
the additive discipline protects v1–v3 ROWS, not the literal; `docs/inference-trace.md` schema table updated to v4 with a schema history note.
2. **trace["blocks"] user-entry re-measure:** the frozen test demanded `per_block_tokens["user"]` match post-decoration; the
re-measure therefore also updates the admitted user entry in the broker trace dict (not only `block.tokens` + `capsule.token_count`).
3. **num_ctx degrade path:** added a `num_ctx <= 0` fallback to the discovered window for stand-in brokers (trace missing
window/budget keys) — degrade-not-crash, beyond the literal one-liner, pinned by `test_num_ctx_falls_back_to_window_when_budget_basis_missing`.
4. **Trace v4 extraction is Mock-hardened:** pre-existing frozen tests construct the capsule as a `Mock`; the operator's
hash/message extraction guards non-list/non-dict content and falls back gracefully (no fidelity evidence on stand-in capsules).
5. **`capture_messages` deep-copies** the sent list (test-pinned) so caller-side mutation can never diverge the logged payload.
**Reviewer flags:**
- `InferenceTrace.capsule_messages` logs the FULL message payload per run — accepted per plan (message list, not raw blocks);
watch JSONL rotation headroom if history blocks grow (#28 compaction bounds this).
- `docs/inference-trace.md` "Record schema (version 4)" supersedes the version-1 table heading; no other docs referenced v3.
- Authority map: `attention_capsule.py` entry now `retain-authority` under ContextBroker (context_construction);
`test_v1_authority_map.py` green (path existence, disposition vocabulary).
@@ -0,0 +1,31 @@
# Research-gate evidence enforcement (pipeline hardening) — Plan
> **Contract:** controller-authored (controller-sized change), independent review gate + protected CI still apply. Conventional commit referencing the tranche epic #147 (§3.4-adjacent gate integrity). No deviation without plan-tail record.
## Goal
Nick's directive: research rounds must actually PULL sources so downstream stages work on evidence. Today `research-clear` clears on four regex keywords — a round of pure improvisation clears identically to a genuine one (observed this session). The gate must verify EVIDENCE per phase.
## Change (single job edit: `.gitea/workflows/pipeline-gate.yml` research-clear)
Split the comment body into phase sections (on the phase headers). Require:
- **INWARD:** ≥2 codebase citations matching `(src|tests|scripts|docs|.gitea)/...` path pattern (evidence the code was actually read).
- **OUTWARD:** ≥2 UNIQUE external http(s) URLs, excluding the forge's own host (self-references are not external sources) — evidence sources were actually pulled.
- **INNOVATION + SYNTHESIS:** ≥1 total URL reference OR a `plans/` path mention (the synthesis must tie back to sources or a concrete plan artifact).
- Missing evidence → the job posts a comment naming exactly what's missing (per phase), research-pending STAYS, no label transition. Existing success path unchanged otherwise.
## Non-goals
- No LLM judging in CI (anti-complexity; regex evidence floor only).
- No change to gate/teach jobs; no new labels.
## Tasks
1. Edit research-clear script: section splitter + evidence counters + rejection comment. Verify YAML parses; henxels exit 0; all canaries green (unchanged src → suite untouched but CI will run it on PR anyway).
2. Commit `feat(pipeline): research gate requires evidence (sources in OUTWARD, citations in INWARD) (#147)`; push; PR; protected merge.
## Acceptance
- A sourceless OUTWARD no longer clears the gate (rejection comment received).
- An evidenced round (URLs + citations) clears as before.
- Verified live: the next tranche research round (posted with general-3's pulled sources) passes the new gate.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Enforce the bug-fix discharge contract (docs/bugfix-contract.md) (#147).
A PR that claims discharges must carry, for every `Discharge:` finding line in
its body, at least one ADDED test hunk in the named test file. Structural check:
the pipeline cannot read intent, but it can verify the shape of the claim.
Convention (PR body):
Discharge: F1 -> tests/test_foo.py
Discharge: F2 -> tests/test_bar.py::test_case
Exit 0 when no Discharge lines exist (not a fix PR) or every line is satisfied.
Exit 1 with the unsatisfied finding ids otherwise. Silent fixes (fix commits
with no discharge claim AND no test hunks) are a reviewer question, not a
script verdict — see the contract's "Why".
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
DISCHARGE_RE = re.compile(r"^\s*Discharge:\s*(?P<id>\S+)\s*->\s*(?P<file>\S+)", re.MULTILINE)
TEST_PATH_RE = re.compile(r"(tests|src/rune)/.*?test.*\.py$")
def added_test_hunks(diff: str, test_file: str) -> bool:
"""True if the diff adds lines inside the named test file."""
current_file: str | None = None
for line in diff.splitlines():
if line.startswith("+++ b/"):
current_file = line[6:].strip()
elif line.startswith("+") and not line.startswith("+++"):
if current_file and (current_file == test_file or current_file.endswith("/" + test_file)):
if TEST_PATH_RE.search(current_file):
return True
return False
def main() -> int:
pr_body_path = os.environ.get("PR_BODY_FILE")
if not pr_body_path or not os.path.exists(pr_body_path):
print("check_discharge_contract: no PR body provided — nothing to check (exit 0)")
return 0
body = open(pr_body_path, encoding="utf-8").read()
claims = DISCHARGE_RE.findall(body)
if not claims:
print("check_discharge_contract: no Discharge claims in PR body (exit 0)")
return 0
base = os.environ.get("PR_BASE_SHA")
head = os.environ.get("PR_HEAD_SHA") or "HEAD"
if not base:
print("check_discharge_contract: PR_BASE_SHA missing — cannot diff (exit 1)")
return 1
diff = subprocess.run(
["git", "diff", base, head, "--", "tests/", "src/rune/"],
capture_output=True,
text=True,
check=True,
).stdout
unsatisfied = []
for fid, test_file in claims:
if not added_test_hunks(diff, test_file):
unsatisfied.append(f"{fid} -> {test_file}")
if unsatisfied:
print("check_discharge_contract: DISCHARGE CLAIMS WITHOUT RED TESTS (contract docs/bugfix-contract.md):")
for u in unsatisfied:
print(f" - {u}")
print("Every finding needs a failing test hunk in the named file BEFORE the fix. See docs/bugfix-contract.md.")
return 1
print(f"check_discharge_contract: {len(claims)} discharge claim(s) verified against added test hunks")
return 0
if __name__ == "__main__":
sys.exit(main())
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""merge_robot.py — deterministic merge-on-green for this repo (#203).
Extracted from the inline workflow script so its logic is TESTABLE
(bug-fix contract, docs/bugfix-contract.md: the silent no-op defect).
Behavior:
- GET /user first: verify the token's identity and log it. Scoped-token
failures are diagnosable from the job log instead of mysterious.
- Scan open PRs; for each: combined status must be success AND mergeable.
- Merge refusals (403/405/anything non-(200,405-already-merged)) FAIL THE JOB
with the full error body — never print-and-continue. A robot that cannot
merge must be visibly broken, not silently idle.
- 405 with "behind the base branch" is retried once after a self-update of
the head (informational), then fails loudly if still refused.
- Every scanned PR produces a decision line: merged / skipped(reason).
"""
from __future__ import annotations
import json
import sys
import time
import urllib.error
import urllib.request
from typing import Any
class MergeRefused(Exception):
"""A merge the robot expected to perform was refused by the forge."""
def _api(
base: str, token: str, path: str, method: str = "GET", data: Any = None, ok: tuple = (200,)
) -> tuple[int, Any]:
req = urllib.request.Request(
base + path,
data=json.dumps(data).encode() if data is not None else None,
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
status = r.status
except urllib.error.HTTPError as e:
status = e.code
body = e.read()
except urllib.error.URLError as e:
raise MergeRefused(f"forge unreachable at {path}: {e.reason}") from e
parsed: Any = json.loads(body) if body else {}
if method == "GET" and status not in ok:
raise MergeRefused(f"GET {path} -> {status}: {str(parsed)[:300]}")
return status, parsed
def verify_identity(base: str, token: str) -> str:
status, user = _api(base, token, "/user")
login = user.get("login", "?")
print(f"merge-robot: authenticated as {login} (status {status})")
return login
def scan(base: str, token: str) -> list[dict]:
status, prs = _api(base, token, "/pulls?state=open&limit=50")
print(f"merge-robot: {len(prs)} open PR(s)")
return prs
def decide(base: str, token: str, pr: dict) -> str:
"""Return a decision line for one PR; raises MergeRefused on unexpected refusals."""
n = pr["number"]
sha = pr["head"]["sha"]
status, cs = _api(base, token, f"/commits/{sha}/status")
state = cs.get("state")
if state != "success":
return f"#{n}: skipped (status={state})"
status, detail = _api(base, token, f"/pulls/{n}")
if not detail.get("mergeable", False):
return f"#{n}: skipped (not mergeable)"
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
if mstatus == 200:
return f"#{n}: MERGED"
if mstatus == 405:
msg = str(mresp.get("message", ""))
if "behind" in msg.lower():
return f"#{n}: skipped (head behind base — needs branch update): {msg}"
raise MergeRefused(f"#{n} merge refused 405: {msg}")
if mstatus == 403:
raise MergeRefused(f"#{n} merge FORBIDDEN 403 — token lacks pull-request write scope: {str(mresp)[:300]}")
raise MergeRefused(f"#{n} merge failed {mstatus}: {str(mresp)[:300]}")
def main_with(argv: list[str]) -> int:
token = argv[1] if len(argv) > 1 else ""
base = argv[2] if len(argv) > 2 else ""
if not token or not base:
print("usage: merge_robot.py <token> <api-base>")
return 2
try:
verify_identity(base, token)
prs = scan(base, token)
merged = 0
for pr in prs:
line = decide(base, token, pr)
print("merge-robot:", line)
if "MERGED" in line:
merged += 1
time.sleep(1)
print(f"merge-robot: done — {merged} merged of {len(prs)} scanned")
return 0
except MergeRefused as exc:
print(f"merge-robot: FAILING JOB — {exc}", file=sys.stderr)
return 1
def main() -> int:
return main_with(sys.argv)
if __name__ == "__main__":
sys.exit(main())
+39 -2
View File
@@ -15,6 +15,7 @@ import json
import os
import sys
import re
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List
@@ -89,8 +90,32 @@ class AttentionCapsule:
messages.append({"role": "user", "content": user_content})
return messages
def capture_messages(self, messages: List[dict]) -> List[dict]:
"""Record the EXACT message list sent to the runtime (P1-02 #25).
Stored post-hoc on ``capsule.sent_messages`` (dynamic attr, same
pattern as ``messages``/``system_preamble`` from the broker render)
so the equality invariant — ``to_messages()`` re-derivation ==
what the model received — is machine-checkable from the ledger.
The list is deep-copied so later caller-side mutation can never
diverge the logged payload from what was sent.
"""
self.sent_messages = json.loads(json.dumps(list(messages)))
return self.sent_messages
def to_prompt(self) -> str:
"""Convert capsule to prompt text."""
"""Convert capsule to prompt text.
.. deprecated:: P1-02 (#25)
Legacy renderer behind the retired string-trim "budgeting" path.
The ContextBroker owns prompt assembly; use ``to_messages()``.
"""
warnings.warn(
"attention_capsule.AttentionCapsule.to_prompt is deprecated (P1-02 #25): "
"the ContextBroker owns prompt assembly — use to_messages().",
DeprecationWarning,
stacklevel=2,
)
parts = []
if self.active_objective:
@@ -135,9 +160,21 @@ def estimate_tokens(text: str) -> int:
def create_capsule(task: str, context: str = "", max_tokens: int = 600) -> AttentionCapsule:
"""Create an attention capsule for a task.
Only includes what's relevant to the current task.
.. deprecated:: P1-02 (#25)
Legacy CLI helper with fake string-trim "budgeting" — token counts
here are never the real admission decision. The ContextBroker
(#19) is the sole prompt-assembly authority. Kept only for the
standalone CLI above; no live callers.
"""
warnings.warn(
"attention_capsule.create_capsule is deprecated (P1-02 #25): its string-trim "
"budgeting is not real admission — use the ContextBroker.",
DeprecationWarning,
stacklevel=2,
)
capsule = AttentionCapsule()
# Extract objective from task
+2 -2
View File
@@ -247,8 +247,8 @@ def check_scripted_operator_wiring() -> int:
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 3:
return _fail(f"trace schema_version must be 3, got {record['schema_version']}")
if record["schema_version"] != 4:
return _fail(f"trace schema_version must be 4, got {record['schema_version']}")
if record["selected_tools"] != expected:
return _fail(f"trace selected_tools wrong: {record['selected_tools']}")
if not record["capability_reason"]:
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""P1-02 capsule-fidelity canary (#25).
Self-bootstrapping like canary_p1_07.py (CI runs ``python3 <canary>``
without PYTHONPATH). Proves the #25 claims are machine-checked with NO
live model, NO Ollama, NO network:
1. trace.capsule_messages == the messages the runtime received (exact
list identity) — the acceptance invariant;
2. capsule_token_count is truthful post-safety-override decoration;
3. num_ctx == capsule.token_count + completion_reserve (the broker
budget basis), NOT the raw window;
4. content hashes match the block contents;
5. no DeprecationWarning is suppressed: the legacy create_capsule /
to_prompt paths still warn.
Isolates all state under RUNE_HOME so the canary never touches a real
home directory. Prints 'P1-02 CAPSULE FIDELITY CANARY PASSED' and exits 0.
"""
import contextlib
import hashlib
import json
import os
import sys
import tempfile
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
# Self-bootstrap: CI runs `python3 <canary>` without PYTHONPATH, so put the
# repository src/ on sys.path before importing rune modules. Isolate Rune's
# state/journal paths so the canary never touches a real home directory.
_repo_src = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, _repo_src)
_canary_home = tempfile.mkdtemp(prefix="p1-02-canary-home-")
os.environ["RUNE_HOME"] = _canary_home
os.environ["RUNE_MEMORY_DIR"] = tempfile.mkdtemp(prefix="p1-02-canary-mem-")
from rune import rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ContributionBlock # noqa: E402
from rune.inference_trace import read_traces # noqa: E402
from rune.local_tool_runtime import OllamaChatTransport # noqa: E402
from rune.tool_protocol import OperatorResponse # noqa: E402
WINDOW = 40960
BUDGET = 32768
def _fail(message: str) -> int:
print(f"FAILED: {message}", file=sys.stderr)
return 1
class _ScriptedTransport(OllamaChatTransport):
"""OllamaChatTransport with _post scripted — real accumulation code runs."""
def __init__(self):
super().__init__()
def _post(self, path, payload):
return {
"model": "qwen3:8b",
"created_at": "2026-09-05T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": True,
"prompt_eval_count": 100,
"eval_count": 5,
"total_duration": 1_000_000_000,
}
class _ScriptedRuntime:
"""Minimal runtime stand-in: one chat, then the canned final response."""
def __init__(self, transport):
self.transport = transport
self.tool_names = ("read_file", "write_file")
self.received_messages: list = []
self.received_options: dict = {}
def run(self, messages, **kwargs):
self.received_messages = [dict(m) for m in messages]
self.received_options = dict(kwargs.get("options") or {})
self.transport.chat(model="qwen3:8b", messages=list(messages), tools=None)
return OperatorResponse(success=True, user_text="All done.", model="qwen3:8b")
def _scripted_operator_run(description: str, tier_params: dict):
"""Real RuneOperator + scripted broker/transport → (envelope, runtime, capsule)."""
storage = Path(_canary_home) / "operator.json"
op = rune_operator.RuneOperator(str(storage))
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", len("I am Rune.") // 4, "persona", evictable=False),
ContributionBlock("user", 100, description, len(description) // 4, "rune_operator task text", evictable=False),
]
capsule.token_count = sum(b.tokens for b in capsule.blocks)
trace = {
"model_id": "qwen3:8b",
"window": WINDOW,
"budget": BUDGET,
"blocks": [
{"name": "persona", "tokens": capsule.blocks[0].tokens, "admitted": True, "reason": ""},
{"name": "user", "tokens": capsule.blocks[1].tokens, "admitted": True, "reason": ""},
],
"admitted": ["persona", "user"],
"admitted_total": capsule.token_count,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
runtime = _ScriptedRuntime(_ScriptedTransport())
profile = SimpleNamespace(safety_tier=0)
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime),
patch.object(rune_operator, "OllamaChatTransport", return_value=runtime.transport),
patch("rune.local_tool_runtime.context_length", lambda model: WINDOW),
patch.object(rune_operator.safety_override.SafetyOverride, "get_params", lambda self, text: dict(tier_params)),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
]
with contextlib.ExitStack() as stack:
for item in patches:
stack.enter_context(item)
stack.enter_context(patch("rune.event_bus._store_path", lambda: Path(storage).parent / "journal-events.jsonl"))
envelope = op.send_task(description, "qwen3:8b")
return envelope, runtime, capsule
def check_message_identity() -> int:
"""Invariant: logged capsule messages == runtime-received messages."""
description = "inspect this code path"
envelope, runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["capsule_messages"] != runtime.received_messages:
return _fail("trace.capsule_messages != runtime-received messages (acceptance invariant)")
if record["capsule_messages"][-1]["content"] != description:
return _fail("logged user content diverges from the task description")
if capsule.sent_messages != capsule.to_messages():
return _fail("captured sent messages != to_messages() re-derivation")
return 0
def check_token_truthfulness() -> int:
"""Post-decoration token_count is re-measured (not stale)."""
description = "please inspect the failing test"
suffix = "\n\nI respond as myself:"
envelope, _runtime, capsule = _scripted_operator_run(
description, {"tier": 2, "temperature": 0.95, "prefix": "", "suffix": suffix}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
user_block = next(b for b in capsule.blocks if b.name == "user")
if user_block.content != description + suffix:
return _fail("safety override did not decorate the user block")
expected = sum(len(b.content) // 4 if isinstance(b.content, str) else 0 for b in capsule.blocks)
if capsule.token_count != expected:
return _fail(f"capsule.token_count {capsule.token_count} != re-measured {expected}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
if records[-1]["capsule_token_count"] != expected:
return _fail("trace capsule_token_count not truthful post-decoration")
return 0
def check_num_ctx_budget() -> int:
"""num_ctx == capsule.token_count + completion_reserve, never the window."""
description = "inspect this code path"
envelope, runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
completion_reserve = WINDOW - BUDGET
expected = capsule.token_count + completion_reserve
if runtime.received_options.get("num_ctx") != expected:
return _fail(f"num_ctx {runtime.received_options.get('num_ctx')} != budget basis {expected}")
if runtime.received_options.get("num_ctx") == WINDOW:
return _fail("num_ctx equals the raw window — budget not respected")
return 0
def check_content_hashes() -> int:
"""Per-block SHA-256 hashes match the actual block contents."""
description = "hash integrity probe"
envelope, _runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
hashes = records[-1]["capsule_content_hashes"]
if set(hashes) != {b.name for b in capsule.blocks}:
return _fail(f"hash keys {sorted(hashes)} != block names {sorted(b.name for b in capsule.blocks)}")
for block in capsule.blocks:
payload = (
block.content.encode("utf-8")
if isinstance(block.content, str)
else json.dumps(block.content, ensure_ascii=False, sort_keys=True).encode("utf-8")
)
if hashes[block.name] != hashlib.sha256(payload).hexdigest():
return _fail(f"content hash mismatch for block '{block.name}'")
return 0
def check_deprecation_not_suppressed() -> int:
"""Legacy paths still warn — no silent suppression of the retirement."""
from rune.attention_capsule import create_capsule
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
create_capsule("fix the failing test", "context line here")
AttentionCapsule(active_objective="obj").to_prompt()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
if len(deprecations) < 2:
return _fail(f"expected 2 DeprecationWarnings from legacy paths, got {len(deprecations)}")
return 0
def main() -> int:
checks = (
("trace.capsule_messages == runtime-received messages (identity)", check_message_identity),
("capsule_token_count truthful post-decoration", check_token_truthfulness),
("num_ctx == budget basis, not the raw window", check_num_ctx_budget),
("content hashes match block contents", check_content_hashes),
("legacy deprecation warnings not suppressed", check_deprecation_not_suppressed),
)
for label, check in checks:
result = check()
if result != 0:
return result
print(f" ✓ {label}")
print("P1-02 CAPSULE FIDELITY CANARY PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -198,8 +198,8 @@ def check_reason_recording() -> int:
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 3:
return _fail(f"schema_version must be 3, got {record['schema_version']}")
if record["schema_version"] != 4:
return _fail(f"schema_version must be 4, got {record['schema_version']}")
if record["routing_reason"] != "router: measured 12/13 success task_family=code":
return _fail(f"routing_reason not recorded: {record['routing_reason']!r}")
if record["routing_mode"] != "local":
+9 -1
View File
@@ -79,10 +79,18 @@ class ContributionBlock:
self.evictable = evictable # False = never dropped (system, user)
def to_dict(self) -> dict:
"""Serialize for pending_state embedding (approval resume)."""
"""Serialize for pending_state embedding (approval resume).
P1-02 (#25): ``content`` is included so the embedded capsule is
verifiable end-to-end — the acceptance criterion is that the logged
capsule contents EXACTLY equal the model payload, which token counts
alone cannot prove. Blocks are small (admission-bounded), so the
extra size is bounded by the same budget.
"""
return {
"name": self.name,
"priority": self.priority,
"content": self.content,
"tokens": self.tokens,
"source": self.source,
"evictable": self.evictable,
+23 -8
View File
@@ -25,6 +25,7 @@ Usage:
import argparse
import hashlib
import json
import logging
import os
import re
import sqlite3
@@ -48,6 +49,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from grading import affirm_rule, grade_fact, tier_of
import memory_env
logger = logging.getLogger("curator")
BLOCKS_DIR = os.path.join(memory_env.memory_dir(), "blocks")
JOURNAL_DIR = os.path.join(memory_env.memory_dir(), "journal")
INDEX_FILE = os.path.join(memory_env.memory_dir(), "index", "blocks_meta.json")
@@ -83,6 +86,7 @@ def _aaak(text, topic=""):
import aaak
return aaak.compress(text, topic=topic, source_file="curator")
except Exception:
logger.debug("_aaak failed", exc_info=True)
return ""
@@ -117,6 +121,7 @@ def le_promote_eligible(proposal, existing, strength, min_strength=3,
corpus_texts=corpus_texts)
except Exception:
# Fallback: old behaviour (strength-only) if the lexicon module is gone.
logger.debug("le_promote_eligible failed", exc_info=True)
return {"eligible": strength >= adjusted_min,
"reason": f"lexicon module unavailable — strength gate only (adjusted_min={adjusted_min}, wash_count={wash_count})"}
@@ -131,6 +136,7 @@ def _block_values(label):
db.close()
return [r[0] for r in rows]
except Exception:
logger.debug("_block_values failed", exc_info=True)
return []
@@ -145,6 +151,7 @@ def _store_corpus(limit=200):
db.close()
return [r[0] for r in rows]
except Exception:
logger.debug("_store_corpus failed", exc_info=True)
return []
@@ -169,6 +176,7 @@ def _contradicts_identity(fact):
"AND status='active'").fetchall()
db.close()
except Exception:
logger.debug("_contradicts_identity failed", exc_info=True)
return False
values = [r[0] for r in rows]
if not values:
@@ -176,6 +184,7 @@ def _contradicts_identity(fact):
try:
return le.coherence(fact, values) == "conflict"
except Exception:
logger.debug("_contradicts_identity failed", exc_info=True)
return False
@@ -217,6 +226,7 @@ def store_has(text, topic=None):
db.close()
return n > 0
except Exception:
logger.debug("store_has failed", exc_info=True)
return False
@@ -236,7 +246,7 @@ def read_block(label):
db.close()
body = "\n".join(f"- {r[0]}" for r in rows)
except Exception:
pass
logger.debug("read_block failed", exc_info=True)
return fm, body
@@ -276,11 +286,11 @@ def write_block(label, fm, body):
import memory_store as ms
ms._auto_associate(db, eid, text, topic=label, source="curator")
except Exception:
pass
logger.debug("write_block failed", exc_info=True)
db.commit()
db.close()
except Exception:
pass
logger.debug("write_block failed", exc_info=True)
return block_path(label)
@@ -424,7 +434,7 @@ def _embed(texts):
if v:
cache[t] = v
except Exception:
pass
logger.debug("_embed failed", exc_info=True)
finally:
try:
os.unlink(path)
@@ -566,7 +576,7 @@ def promote_candidates(evidence_items, idx, journal_lines):
try:
audit_ledger.log_promotion(0, "candidate", "promoted", entry["count"], f"candidate seen {entry['count']} times")
except Exception:
pass # Audit logging is non-critical
logger.debug("promote_candidates failed", exc_info=True) # Audit logging is non-critical
# Remove the promoted candidate so it isn't applied repeatedly.
del cands[ck]
@@ -675,6 +685,7 @@ def _grounded_in_sessions(fact, threshold, window_days):
return True
return False
except Exception:
logger.debug("_grounded_in_sessions failed", exc_info=True)
return False
@@ -691,11 +702,13 @@ def _grounded_in_corpus(fact):
hits = ms.chunk_recall(db, fact[:120], budget=3, min_sim=0.78)
cache[fact] = bool(hits)
except Exception:
logger.debug("_grounded_in_corpus failed", exc_info=True)
cache[fact] = False
finally:
db.close()
return cache[fact]
except Exception:
logger.debug("_grounded_in_corpus failed", exc_info=True)
return False
@@ -1103,7 +1116,7 @@ def apply_op(op, idx, journal_lines):
op.get("source", "curator"),
int(op.get("strength", 3) or 3))
except Exception:
pass
logger.debug("apply_op failed", exc_info=True)
return True
elif op["op"] == "UPDATE":
# Only safe when the evidence explicitly names the exact old entry.
@@ -1371,6 +1384,7 @@ def graph_write(fact, evidence="", source="curator", strength=3):
capture_output=True, text=True, timeout=15)
return '"added": True' in (r.stdout or "")
except Exception:
logger.debug("graph_write failed", exc_info=True)
return False
@@ -1453,6 +1467,7 @@ def run_plan(evidence_items, apply=False):
"drift-ledger.py check --strict` to audit")
return journal_lines, ops
except Exception as e:
logger.debug("run_plan failed", exc_info=True)
print(f" (drift check unavailable: {e})")
for op in ops:
if apply:
@@ -1481,7 +1496,7 @@ def run_plan(evidence_items, apply=False):
_dl2.DB = STORE_DB
_dl2.snapshot()
except Exception:
pass
logger.debug("run_plan failed", exc_info=True)
return journal_lines, ops
@@ -1532,7 +1547,7 @@ def main():
})
db.close()
except Exception:
pass
logger.debug("main failed", exc_info=True)
if not evidence_items:
print("No evidence provided and no recent entries found.", file=sys.stderr)
+8 -1
View File
@@ -25,7 +25,7 @@ try:
except ImportError: # direct script execution
from rune.memory_env import rune_home
INFERENCE_TRACE_SCHEMA_VERSION = 3
INFERENCE_TRACE_SCHEMA_VERSION = 4
TRACE_ROTATE_BYTES = 10_000_000
TRACE_BACKUPS = 3
@@ -87,6 +87,13 @@ class InferenceTrace:
selected_tools: list = field(default_factory=list) # ordered exposed set
capability_reason: str = "" # broker selection reason ("" = unselected)
find_capability_calls: int = 0 # escape-hatch round-trips this run
# P1-02 (#25) schema v4 — capsule-fidelity fields, additive. The logged
# capsule contents must EXACTLY equal the bounded dynamic context
# supplied to the model; message list (the actual payload) + per-block
# content SHA-256 (integrity without full JSONL bloat) prove it.
capsule_messages: list = field(default_factory=list) # exact runtime.run payload
capsule_content_hashes: dict = field(default_factory=dict) # block name -> sha256
capsule_token_count: int = 0 # post-decoration re-measured total
def price_for(model: str) -> float | None:
+37 -17
View File
@@ -29,6 +29,7 @@ Run under ~/.venvs/memory/bin/python3 (has sqlite-vec).
import argparse
import hashlib
import json
import logging
import os
import sqlite3
import subprocess
@@ -40,6 +41,8 @@ if _SD not in sys.path:
sys.path.insert(0, _SD)
import memory_env
logger = logging.getLogger("memory_store")
MEM_DIR = memory_env.memory_dir()
STORE_DIR = os.path.join(MEM_DIR, "store")
DB_PATH = os.environ.get("MEMORY_STORE_DB", memory_env.store_db())
@@ -302,7 +305,7 @@ def _embed(texts):
if v:
cache[t] = v[:EMBED_DIM]
except Exception:
pass
logger.debug("_embed failed", exc_info=True)
finally:
try:
os.unlink(path)
@@ -348,6 +351,7 @@ def _aaak_summary(text, topic="", source=""):
import aaak
return aaak.compress(text, topic=topic, source_file=source)
except Exception:
logger.debug("_aaak_summary failed", exc_info=True)
return ""
@@ -375,6 +379,7 @@ def _auto_associate(db, eid, text, topic="", source=""):
(eid,)).fetchall()
except Exception:
# Fallback: if vec0 query fails, skip associations
logger.debug("_auto_associate failed", exc_info=True)
return
total = max(db.execute("SELECT COUNT(*) FROM entries "
@@ -403,7 +408,7 @@ def _auto_associate(db, eid, text, topic="", source=""):
boost = 0.35 + 0.1 * min(n_distinct, 4)
associate(db, eid, other_id, boost=boost)
except Exception:
pass
logger.debug("_auto_associate failed", exc_info=True)
def _df_word(db, word):
@@ -412,6 +417,7 @@ def _df_word(db, word):
"SELECT COUNT(*) AS n FROM entries WHERE status='active' "
"AND text LIKE ?", (f"%{word}%",)).fetchone()["n"]
except Exception:
logger.debug("_df_word failed", exc_info=True)
return 0
@@ -426,7 +432,7 @@ def _run_lexicon_reconcile():
"--fix"],
capture_output=True, text=True, timeout=600)
except Exception:
pass
logger.debug("_run_lexicon_reconcile failed", exc_info=True)
def add_entry(db, text, importance=0.5, topic="", entities=None,
@@ -502,7 +508,7 @@ def recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
except (json.JSONDecodeError, TypeError):
pass # Cache miss on parse, fall through to full retrieval
except (ImportError, Exception):
pass # Cache unavailable, proceed without it
logger.debug("recall failed", exc_info=True) # Cache unavailable, proceed without it
# --- Query expansion (research uplift #1). -----------------------------
_orig_query = query
@@ -515,6 +521,7 @@ def recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
# so we don't blunt the vector signal with synonyms.
query = _orig_query
except Exception:
logger.debug("recall failed", exc_info=True)
_extra = []
# ------------------------------------------------------------------------
qvec = _embed([f"search_query: {query}"]).get(f"search_query: {query}")
@@ -548,6 +555,7 @@ def recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
"SELECT COUNT(*) AS n FROM entries WHERE status='active' "
"AND text LIKE ?", (f"%{word}%",)).fetchone()["n"]
except Exception:
logger.debug("recall failed", exc_info=True)
return 0
dense = []
for r in rows:
@@ -598,7 +606,7 @@ def recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
for _row in _r:
_expanded_hits.add(_row["rowid"])
except Exception:
pass
logger.debug("recall failed", exc_info=True)
if _expanded_hits:
_ids = list(_expanded_hits)[:RECALL_BUDGET * 2]
_ph = ",".join("?" * len(_ids))
@@ -607,7 +615,7 @@ def recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
f"WHERE rowid IN ({_ph})", _ids).fetchall()
bm25 = [(r["rowid"], float(r["score"])) for r in _rows]
except Exception:
pass
logger.debug("recall failed", exc_info=True)
# (3) Fusion. With BOTH rankers: RRF (Cormack et al. 2009). With ONLY
# lexical (embeddings absent): RRF degenerates to a rank transform that
# destroys the score signal needed for thresholding (Bruch et al.,
@@ -667,7 +675,7 @@ def recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
if days >= 0:
recency = RECENCY_HALF_LIFE / (RECENCY_HALF_LIFE + days)
except Exception:
pass
logger.debug("recall failed", exc_info=True)
# In lexical-only mode `base` is a raw BM25 score (negative).
# Normalize it to a positive relevance for the metadata, keeping
# the ranking identical. In hybrid mode `base` is an RRF rank.
@@ -811,6 +819,7 @@ def gate_consolidated(db, query: str) -> dict:
mc = memory_consolidation.MemoryConsolidation()
consolidated = mc.format_knowledge_for_prompt(query)
except Exception:
logger.debug("gate_consolidated failed", exc_info=True)
return {"use_consolidated": False, "reason": "consolidation unavailable", "consolidated": ""}
# Decision factors
@@ -825,6 +834,7 @@ def gate_consolidated(db, query: str) -> dict:
qm = query_memory.QueryMemory()
exploration_rate = qm.adaptive_exploration_rate(query)
except Exception:
logger.debug("gate_consolidated failed", exc_info=True)
exploration_rate = 0.30 # fallback to static rate
explore = random.random() < exploration_rate
@@ -1010,7 +1020,7 @@ def tier_demotion_sweep(db) -> dict:
elif current_tier == "warm" and new_tier == "cold":
demoted["warm_to_cold"] += 1
except Exception:
pass # tier column may not exist yet
logger.debug("tier_demotion_sweep failed", exc_info=True) # tier column may not exist yet
db.commit()
return demoted
@@ -1037,7 +1047,7 @@ def cached_recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
except (json.JSONDecodeError, TypeError):
pass
except (ImportError, Exception):
pass
logger.debug("cached_recall failed", exc_info=True)
# Cache miss — shard routing: route query to correct shard
try:
@@ -1048,6 +1058,7 @@ def cached_recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
# Use shard for recall if available, fall back to main db
recall_db = shard_db if shard_db else db
except Exception:
logger.debug("cached_recall failed", exc_info=True)
recall_db = db
# Gate logic: consolidated vs full retrieval
@@ -1070,7 +1081,7 @@ def cached_recall(db, query, budget=RECALL_BUDGET, min_score=RECALL_MIN_SCORE):
_cache.put_cache(_cache_key, json.dumps(result, ensure_ascii=False),
scope="retrieval")
except (ImportError, Exception):
pass
logger.debug("cached_recall failed", exc_info=True)
return result
@@ -1090,6 +1101,7 @@ def _position_aware(top):
reverse=True)
return [s_sorted[0]] + s_sorted[2:] + [s_sorted[1]]
except Exception:
logger.debug("_position_aware failed", exc_info=True)
return top
@@ -1170,7 +1182,7 @@ def _fts_delete(db, eid):
db.execute("INSERT INTO entries_fts(entries_fts, rowid, text) "
"VALUES('delete', ?, ?)", (eid, old["text"]))
except Exception:
pass
logger.debug("_fts_delete failed", exc_info=True)
def delete_entry(db, eid):
@@ -1218,7 +1230,7 @@ def reindex_entries(db, dry_run=False):
"LEFT JOIN entries e ON e.id = v.rowid WHERE e.id IS NULL"
).fetchone()["c"]
except Exception:
pass
logger.debug("reindex_entries failed", exc_info=True)
try:
fts_dangling = db.execute(
"SELECT COUNT(*) AS c FROM entries_fts f "
@@ -1227,7 +1239,7 @@ def reindex_entries(db, dry_run=False):
fts_total = db.execute(
"SELECT COUNT(*) AS c FROM entries_fts").fetchone()["c"]
except Exception:
pass
logger.debug("reindex_entries failed", exc_info=True)
if dry_run:
return {"indexed": indexed, "total": total, "missing": missing,
"dangling": dangling, "fts_dangling": fts_dangling,
@@ -1241,7 +1253,7 @@ def reindex_entries(db, dry_run=False):
try:
db.execute("DELETE FROM entries_fts")
except Exception:
pass
logger.debug("reindex_entries failed", exc_info=True)
# Contentless FTS5 cannot use DELETE FROM (raises OperationalError).
# The clean reset is DROP + recreate, which also clears dangling rows
# that a DELETE can never touch.
@@ -1251,7 +1263,7 @@ def reindex_entries(db, dry_run=False):
"CREATE VIRTUAL TABLE entries_fts USING fts5("
"text, content='', tokenize='porter unicode61')")
except Exception:
pass
logger.debug("reindex_entries failed", exc_info=True)
rows = db.execute(
"SELECT id, text FROM entries WHERE status='active'").fetchall()
vec_missing = 0
@@ -1265,6 +1277,7 @@ def reindex_entries(db, dry_run=False):
"INSERT INTO entries_vec (rowid, embedding) VALUES (?,?)",
(r["id"], json.dumps(vec)))
except Exception:
logger.debug("reindex_entries failed", exc_info=True)
vec_missing += 1
else:
vec_missing += 1
@@ -1273,6 +1286,7 @@ def reindex_entries(db, dry_run=False):
"INSERT INTO entries_fts (rowid, text) VALUES (?,?)",
(r["id"], text))
except Exception:
logger.debug("reindex_entries failed", exc_info=True)
fts_missing += 1
db.commit()
return {"indexed": len(rows), "total": total, "missing": missing,
@@ -1418,7 +1432,7 @@ def chunk_recall(db, query, budget=6, wing=None, min_sim=0.0, doc=None):
(_fts_query(query), budget * 3)).fetchall()
bm25 = [(r["rowid"], 0.5) for r in rows]
except Exception:
pass
logger.debug("chunk_recall failed", exc_info=True)
ranks = {}
for lst in [dense, bm25]:
for pos, (eid, _s) in enumerate(lst):
@@ -1478,6 +1492,7 @@ def _extract_epub(path):
id_map = dict(_re.findall(r'id="([^"]+)"\s+href="([^"]+)"', opf_text))
manifest_order = [id_map[i] for i in spine_ids if i in id_map]
except Exception:
logger.debug("_extract_epub failed", exc_info=True)
manifest_order = []
if not manifest_order:
manifest_order = [n for n in z.namelist()
@@ -1513,6 +1528,7 @@ def _extract_epub(path):
continue
return "\n\n".join(parts)
except Exception:
logger.debug("_extract_epub failed", exc_info=True)
r = subprocess.run(["ebook2text", path], capture_output=True,
text=True, timeout=60)
return r.stdout
@@ -1541,6 +1557,7 @@ def mine_file(db, path, wing="", room="", chunk_size=1500):
text = "\n\n".join(p["text"] for p in pages if p.get("text"))
except Exception:
# fallback: legacy extractor if the upgraded one is unavailable
logger.debug("mine_file failed", exc_info=True)
r = subprocess.run(["pdftotext", "-layout", path, "-"],
capture_output=True, text=True, timeout=60)
text = r.stdout
@@ -1559,6 +1576,7 @@ def mine_file(db, path, wing="", room="", chunk_size=1500):
else:
return 0, f"unsupported ext {ext}"
except Exception as e:
logger.debug("mine_file failed", exc_info=True)
return 0, str(e)
if not text or not text.strip():
return 0, "empty after parse"
@@ -1974,6 +1992,7 @@ def main():
row = db.execute("SELECT value FROM meta WHERE key='lexicon_version'").fetchone()
cur_ver = int(row["value"]) if row else 0
except Exception:
logger.debug("main failed", exc_info=True)
cur_ver = 0
if cur_ver < LEXICON_VERSION:
_run_lexicon_reconcile()
@@ -2010,6 +2029,7 @@ def main():
"reason": res.get("receipt", {}).get("reason", "")}))
ok = True
except Exception as e:
logger.debug("main failed", exc_info=True)
print(json.dumps({"added": False, "error": str(e)[:120]}))
ok = False
if ok and args.version:
@@ -2021,7 +2041,7 @@ def main():
_vm = _u.module_from_spec(_sp); _sp.loader.exec_module(_vm)
_vm.snapshot(reason="memory-write", summary=text[:80])
except Exception:
pass
logger.debug("main failed", exc_info=True)
sys.exit(0 if ok else 1)
if args.cmd == "recall":
q = " ".join(args.arg)
+75 -6
View File
@@ -13,6 +13,7 @@ Usage:
rune_operator.py list # List available models
"""
import hashlib
import json
import os
import re
@@ -1654,10 +1655,27 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Safety override decorates the final user BLOCK in place
# (P0-03 constraint d); rendering flows from the blocks.
# P1-02 (#25): after mutation the block is RE-MEASURED so
# capsule.token_count (and the broker trace's per-block
# entry) stay truthful post-decoration — the override grows
# the payload ≤ ~100 tokens and admission left headroom, so
# no re-admission is needed; measurement is not budgeting.
for block in capsule.blocks:
if block.name == "user":
block.content = override_prefix + task.description + override_suffix
block.tokens = len(block.content) // 4
for trace_block in trace["blocks"]:
if trace_block.get("name") == "user" and trace_block.get("admitted"):
trace_block["tokens"] = block.tokens
capsule.token_count = sum(
block.tokens for block in capsule.blocks if hasattr(block, "tokens")
)
messages = capsule.to_messages()
# P1-02 (#25) acceptance: the log must capture the EXACT
# payload the runtime receives — capture BEFORE run, assert
# identity in the frozen test + canary.
if hasattr(capsule, "capture_messages"):
capsule.capture_messages(messages)
runtime = LocalToolRuntime(
transport=OllamaChatTransport(timeout=timeout),
@@ -1666,18 +1684,27 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
tools=capability_selection["tools"],
)
transport = runtime.transport
# P0-03: the runtime window must match the broker's budget
# basis — a hardcoded 8192 would silently truncate blocks the
# broker admitted against a larger discovered window. The
# other 13 num_ctx sites remain pinned (follow-up issue).
from rune.local_tool_runtime import context_length as _ctx_len
# P1-02 (#25) one-line num_ctx fix: pass the broker budget
# basis — capsule.token_count + completion_reserve (window −
# budget) — NOT the raw window, so the runtime respects the
# broker's admission decision (#28 owns model-aware budgets).
completion_reserve = 0
if trace.get("window") is not None and trace.get("budget") is not None:
completion_reserve = max(0, int(trace["window"]) - int(trace["budget"]))
num_ctx = int(capsule.token_count) + completion_reserve
if num_ctx <= 0:
# Degrade-not-crash (stand-in broker/trace): keep the
# discovered window rather than sending num_ctx=0.
from rune.local_tool_runtime import context_length as _ctx_len
num_ctx = _ctx_len(model)
response = runtime.run(
messages,
options={
"temperature": temperature,
"num_predict": 4096,
"num_ctx": _ctx_len(model),
"num_ctx": num_ctx,
"stop": ["\n\nUser:", "\n\n---"],
},
think=True,
@@ -1790,6 +1817,35 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
record_trace,
)
# P1-02 (#25) schema v4: capsule fidelity evidence. The
# captured sent messages (post-decoration, pre-run) plus
# per-block SHA-256 integrity hashes and the re-measured
# token total. Degrade-not-crash (P0-06): a stand-in
# capsule (Mock/legacy) must never fail the trace —
# everything is guarded; the record just omits evidence
# that cannot be derived.
capsule_hashes: dict = {}
for _b in list(getattr(capsule, "blocks", None) or []):
content = getattr(_b, "content", None)
name = getattr(_b, "name", "")
if not isinstance(name, str) or not name or content is None:
continue
if isinstance(content, str):
payload = content.encode("utf-8")
elif isinstance(content, (list, dict)):
payload = json.dumps(content, ensure_ascii=False, sort_keys=True).encode("utf-8")
else:
continue
capsule_hashes[name] = hashlib.sha256(payload).hexdigest()
_sent = getattr(capsule, "sent_messages", None)
if not isinstance(_sent, list):
try:
_sent = capsule.to_messages()
except Exception:
_sent = []
if not isinstance(_sent, list):
_sent = []
trace_record = InferenceTrace(
schema_version=INFERENCE_TRACE_SCHEMA_VERSION,
provider=getattr(transport, "provider", "unknown"),
@@ -1831,6 +1887,19 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
find_capability_calls=sum(
1 for event in response.tool_events if event.request.name == "find_capability"
),
# P1-02 (#25) schema v4: capsule fidelity fields —
# the logged messages EXACTLY equal the runtime.run
# payload (captured pre-run on the mutated capsule).
capsule_messages=[
dict(m) for m in _sent if isinstance(m, dict)
],
capsule_content_hashes=capsule_hashes,
capsule_token_count=(
int(capsule.token_count)
if isinstance(getattr(capsule, "token_count", 0), int)
and not isinstance(getattr(capsule, "token_count", 0), bool)
else 0
),
)
record_trace(trace_record)
# P1-06 (quality I-2 discharge): CLOSE the measured loop —
+29
View File
@@ -23,6 +23,7 @@ import copy
import difflib
import hashlib
import json
import logging
import os
import shlex
import sys
@@ -33,6 +34,8 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger("tool_executor")
# ═══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════════
@@ -518,6 +521,7 @@ def _backup_file(path: Path) -> Optional[str]:
shutil.copy2(str(path), backup_path)
return backup_path
except Exception:
logger.debug("_backup_file failed", exc_info=True)
return None
@@ -543,6 +547,7 @@ def undo_last():
return original, f"Restored {original}"
return None, "Could not determine original path"
except Exception as e:
logger.debug("undo_last failed", exc_info=True)
return None, f"Undo failed: {e}"
@@ -568,6 +573,7 @@ def generate_diff(path: Path, new_content: str) -> str:
)
return "".join(diff)
except Exception as e:
logger.debug("generate_diff failed", exc_info=True)
return f"[diff error: {e}]"
@@ -626,6 +632,7 @@ def _execute_tool_now(tool_name: str, args: dict) -> dict:
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
except Exception as e:
logger.debug("_execute_tool_now failed", exc_info=True)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
@@ -860,6 +867,7 @@ def _read_file(args: dict) -> dict:
return {"success": True, "result": result}
except Exception as e:
logger.debug("_read_file failed", exc_info=True)
return {"success": False, "error": f"Read failed: {e}"}
@@ -912,6 +920,7 @@ def _apply_content(path: str, content: str) -> dict:
os.replace(tmp, str(resolved))
return {"success": True, "result": f"Wrote {len(content)} chars to {resolved.name}"}
except Exception as e:
logger.debug("_apply_content failed", exc_info=True)
return {"success": False, "error": f"Write failed: {e}"}
@@ -938,6 +947,7 @@ def _list_directory(args: dict) -> dict:
entries.append(f"{prefix}{item.name}{'/' if item.is_dir() else ''}")
return {"success": True, "result": "\n".join(entries) if entries else "(empty)"}
except Exception as e:
logger.debug("_list_directory failed", exc_info=True)
return {"success": False, "error": f"List failed: {e}"}
@@ -972,9 +982,11 @@ def _search_files(args: dict) -> dict:
"result": "\n".join(matches) + "\n[truncated — 50 matches]",
}
except Exception:
logger.debug("_search_files failed", exc_info=True)
continue
return {"success": True, "result": "\n".join(matches) if matches else "(no matches)"}
except Exception as e:
logger.debug("_search_files failed", exc_info=True)
return {"success": False, "error": f"Search failed: {e}"}
@@ -1020,6 +1032,7 @@ def _run_command(args: dict) -> dict:
except subprocess.TimeoutExpired:
return {"success": False, "error": "Command timed out (30s)"}
except Exception as e:
logger.debug("_run_command failed", exc_info=True)
return {"success": False, "error": f"Command failed: {e}"}
@@ -1071,6 +1084,7 @@ def _edit_file(args: dict) -> dict:
"content": new_content,
}
except Exception as e:
logger.debug("_edit_file failed", exc_info=True)
return {"success": False, "error": f"Edit failed: {e}"}
@@ -1105,6 +1119,7 @@ def _glob_files(args: dict) -> dict:
return {"success": True, "result": "\n".join(formatted) if formatted else "(no matches)"}
except Exception as e:
logger.debug("_glob_files failed", exc_info=True)
return {"success": False, "error": f"Glob failed: {e}"}
@@ -1145,6 +1160,7 @@ def _grep_files(args: dict) -> dict:
except subprocess.TimeoutExpired:
return {"success": False, "error": "Search timed out"}
except Exception as e:
logger.debug("_grep_files failed", exc_info=True)
return {"success": False, "error": f"Grep failed: {e}"}
@@ -1246,6 +1262,7 @@ def _lint_file(args: dict) -> dict:
else:
return {"success": False, "error": result.stdout or result.stderr}
except Exception as e:
logger.debug("_lint_file failed", exc_info=True)
return {"success": False, "error": f"Lint failed: {e}"}
@@ -1286,6 +1303,7 @@ def _run_tests(args: dict) -> dict:
except subprocess.TimeoutExpired:
return {"success": False, "error": "Tests timed out (120s)"}
except Exception as e:
logger.debug("_run_tests failed", exc_info=True)
return {"success": False, "error": f"Test failed: {e}"}
@@ -1315,6 +1333,7 @@ def _format_code(args: dict) -> dict:
else:
return {"success": False, "error": result.stdout or result.stderr}
except Exception as e:
logger.debug("_format_code failed", exc_info=True)
return {"success": False, "error": f"Format failed: {e}"}
@@ -1340,6 +1359,7 @@ def _git_diff(args: dict) -> dict:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return {"success": True, "result": result.stdout or "(no changes)"}
except Exception as e:
logger.debug("_git_diff failed", exc_info=True)
return {"success": False, "error": f"Git diff failed: {e}"}
@@ -1358,6 +1378,7 @@ def _git_log(args: dict) -> dict:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return {"success": True, "result": result.stdout or "(no commits)"}
except Exception as e:
logger.debug("_git_log failed", exc_info=True)
return {"success": False, "error": f"Git log failed: {e}"}
@@ -1387,6 +1408,7 @@ def _git_commit(args: dict) -> dict:
else:
return {"success": False, "error": result.stderr or "Commit failed"}
except Exception as e:
logger.debug("_git_commit failed", exc_info=True)
return {"success": False, "error": f"Git commit failed: {e}"}
@@ -1420,6 +1442,7 @@ def _repo_map(args: dict) -> dict:
# Fallback: simple function/class extraction via grep
return _repo_map_fallback(resolved)
except Exception:
logger.debug("_repo_map failed", exc_info=True)
return _repo_map_fallback(resolved)
@@ -1446,6 +1469,7 @@ def _repo_map_fallback(path: Path) -> dict:
results.append(f"## {lang}")
results.extend(lines)
except Exception:
logger.debug("_repo_map_fallback failed", exc_info=True)
continue
return {"success": True, "result": "\n".join(results) if results else "(no symbols found)"}
@@ -1475,6 +1499,7 @@ def _read_persona(args: dict) -> dict:
persona = content[persona_start:persona_end] if persona_end != -1 else content[persona_start:]
return {"success": True, "result": persona[:5000]}
except Exception as e:
logger.debug("_read_persona failed", exc_info=True)
return {"success": False, "error": f"Failed to read persona: {e}"}
@@ -1507,6 +1532,7 @@ def _query_memory(args: dict) -> dict:
finally:
db.close()
except Exception as e:
logger.debug("_query_memory failed", exc_info=True)
return {"success": False, "error": f"Memory query failed: {e}"}
@@ -1534,6 +1560,7 @@ def _check_growth(args: dict) -> dict:
else:
return {"success": True, "result": "(no growth entries)"}
except Exception as e:
logger.debug("_check_growth failed", exc_info=True)
return {"success": False, "error": f"Growth check failed: {e}"}
@@ -1587,6 +1614,7 @@ def _diagnose_behavior(args: dict) -> dict:
return {"success": True, "result": json.dumps(diagnosis, indent=2)}
except Exception as e:
logger.debug("_diagnose_behavior failed", exc_info=True)
return {"success": False, "error": f"Diagnosis failed: {e}"}
@@ -1617,6 +1645,7 @@ def _find_capability(args: dict) -> dict:
import capability_broker as broker
matches = broker.find_capability(query)
except Exception as exc:
logger.debug("_find_capability failed", exc_info=True)
return {"success": False, "error": f"find_capability failed: {exc}"}
if not matches:
+27 -16
View File
@@ -28,6 +28,7 @@ Usage:
import argparse
import json
import logging
import os
import re
import sys
@@ -37,6 +38,8 @@ _SD = os.path.dirname(os.path.abspath(__file__))
if _SD not in sys.path: sys.path.insert(0, _SD)
import memory_env
logger = logging.getLogger("warm_router")
MEM_DIR = memory_env.memory_dir()
STATE = os.path.join(MEM_DIR, "index", "warm_state.json")
STORE_DB = memory_env.store_db()
@@ -112,7 +115,7 @@ def record_outcome(session_id, outcome="helpful"):
finally:
db.close()
except Exception:
pass
logger.debug("record_outcome failed", exc_info=True)
def record_neuron_fire(entry_id, score=0.0, session_id="", outcome="unknown"):
@@ -130,7 +133,7 @@ def record_neuron_fire(entry_id, score=0.0, session_id="", outcome="unknown"):
finally:
db.close()
except Exception:
pass # Non-critical — don't break firing
logger.debug("record_neuron_fire failed", exc_info=True) # Non-critical — don't break firing
def base_level_activation(entry_id, db=None):
@@ -176,13 +179,14 @@ def base_level_activation(entry_id, db=None):
return max(-2.0, min(b_i, 2.0))
except Exception:
logger.debug("base_level_activation failed", exc_info=True)
return -2.0 # Graceful fallback
finally:
if _own_db and db is not None:
try:
db.close()
except Exception:
pass
logger.debug("base_level_activation failed", exc_info=True)
def _db():
@@ -196,6 +200,7 @@ def _aaak(text):
import aaak
return aaak.compress(text, topic="warm")
except Exception:
logger.debug("_aaak failed", exc_info=True)
return ""
@@ -224,7 +229,7 @@ def list_neurons():
).fetchall()
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
except Exception as exc: # noqa: BLE001 — degrade recorded via subsystem_health (P0-06)
from rune.subsystem_health import record_failure
record_failure("warm_router", exc, tier=1, context="list_neurons")
@@ -304,11 +309,11 @@ def write_neuron(slug, triggers, body, importance=0.5):
source="warm_router")
finally:
dbw.close()
except Exception as exc: # P0-06: observable degrade (T1)
except Exception as exc: # noqa: BLE001 — degrade recorded via subsystem_health (P0-06)
from rune.subsystem_health import record_failure
record_failure("warm_router", exc, tier=1, context="auto-associate")
except Exception as exc: # P0-06: observable degrade (T1)
except Exception as exc: # noqa: BLE001 — degrade recorded via subsystem_health (P0-06)
from rune.subsystem_health import record_failure
record_failure("warm_router", exc, tier=1, context="write_neuron")
@@ -357,7 +362,7 @@ def score_neuron(neuron, low_text, session_context=None, query_vec=None):
if na and nb:
semantic_score = dot / (na * nb)
except Exception:
pass # Semantic scoring is optional — keyword fallback works
logger.debug("score_neuron failed", exc_info=True) # Semantic scoring is optional — keyword fallback works
# Signal 3: Context match (if session context provided)
context_score = 0.0
@@ -420,7 +425,7 @@ def score_neuron(neuron, low_text, session_context=None, query_vec=None):
score += cache_boost
cache.access(entry_id)
except Exception:
pass
logger.debug("score_neuron failed", exc_info=True)
# Signal 7: Body-knowledge relevance (Souveraine enhancement)
body_knowledge = neuron.get("body_knowledge", "")
@@ -492,13 +497,14 @@ def route(text, max_tokens=DEFAULT_MAX_TOKENS, max_blocks=None,
if primed:
session_context = " ".join(primed.keys())
except Exception:
pass
logger.debug("route failed", exc_info=True)
# Pre-compute query embedding once (audit fix: was N HTTP calls)
try:
import memory_store as _ms
_query_vec = _ms._embed([low]).get(low)
except Exception:
logger.debug("route failed", exc_info=True)
_query_vec = None
scored = [(score_neuron(n, low, session_context=session_context,
@@ -567,7 +573,7 @@ def route(text, max_tokens=DEFAULT_MAX_TOKENS, max_blocks=None,
"via_association": True})
used_tokens += a["tokens"]
except Exception:
pass
logger.debug("route failed", exc_info=True)
# Record what fired this turn for the next turn's priming.
if session_id and fired:
try:
@@ -577,7 +583,7 @@ def route(text, max_tokens=DEFAULT_MAX_TOKENS, max_blocks=None,
[f["slug"] for f in fired],
_max_score)
except Exception:
pass
logger.debug("route failed", exc_info=True)
# Write accumulated state once (audit fix: was N+1 file writes)
save_state(_route_state)
return fired, len(neurons), [{"slug": n["slug"], "score": round(s, 2)}
@@ -653,7 +659,7 @@ def route_proactive(session_id="", active_tasks=None, recent_errors=None):
if commitments:
queries.append("commitment reminder promise")
except (ImportError, Exception):
pass
logger.debug("route_proactive failed", exc_info=True)
if not queries:
return [], 0, []
@@ -710,7 +716,7 @@ def _associated_neuron_bodies(fired, budget_tokens):
finally:
db.close()
except Exception:
pass
logger.debug("_associated_neuron_bodies failed", exc_info=True)
return out
@@ -728,6 +734,7 @@ def graph_context(term, hops=1, limit=4):
capture_output=True, text=True, timeout=10)
data = json.loads(r.stdout)
except Exception:
logger.debug("graph_context failed", exc_info=True)
return ""
# AUDIT FIX 2026-08-16: graph_memory.query returns a LIST of facts ([] for
# an unknown entity), not a dict — the old `data.get("facts")` crashed with
@@ -805,7 +812,7 @@ def _prime_neurons(session_id, slugs, score):
if (now - last).total_seconds() > 86400:
stale.append(s)
except Exception:
pass
logger.debug("_prime_neurons failed", exc_info=True)
for s in stale:
del st["sessions"][s]
if len(st["sessions"]) > 40:
@@ -866,6 +873,7 @@ def _primed_associations(session_id, limit=4):
finally:
db.close()
except Exception:
logger.debug("_primed_associations failed", exc_info=True)
return []
@@ -875,7 +883,7 @@ def load_state():
with open(STATE) as _fh:
return json.load(_fh)
except Exception:
pass
logger.debug("load_state failed", exc_info=True)
return {"neurons": {}, "divides": []}
@@ -949,12 +957,13 @@ def promote_active(dry_run=False):
(slug, ", ".join(trigs), r["id"]))
promoted += 1
except Exception:
pass
logger.debug("promote_active failed", exc_info=True)
db.commit()
return promoted
finally:
db.close()
except Exception:
logger.debug("promote_active failed", exc_info=True)
return 0
@@ -1078,6 +1087,7 @@ def always_digest(max_tokens=ALWAYS_DIGEST_TOKENS):
finally:
db.close()
except Exception:
logger.debug("always_digest failed", exc_info=True)
return []
out = []
used = 0
@@ -1163,6 +1173,7 @@ def handle_hook(event, data=None):
"fired_slugs": [f.get("slug", "") for f in fired[:5]],
}
except Exception as e:
logger.debug("handle_hook failed", exc_info=True)
return {"status": "error", "event": event, "error": str(e)[:120]}
return {"status": "ok", "event": event, "handler": "warm_router"}
+155
View File
@@ -0,0 +1,155 @@
"""Unit tests for scripts/merge_robot.py — the fail-loud merge-on-green logic.
Bug-fix contract red-first: the original inline robot swallowed merge refusals
(print-and-continue, exit 0) — the silent no-op defect. These tests freeze the
fail-loud behavior: refusals fail the job, identity is verified first, every
scan produces a decision line.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location("merge_robot", ROOT / "scripts" / "merge_robot.py")
mr = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(mr)
class FakeForge:
"""Simulates the SERVER contract (not the client's logic — no circularity)."""
def __init__(self, prs, statuses, merge_results):
self.prs = prs
self.statuses = statuses # sha -> state
self.merge_results = merge_results # pr_number -> (status, body)
self.mergeable: dict[int, bool] = {} # pr_number -> server-reported mergeable
self.calls: list[tuple[str, str]] = []
def get(self, path):
self.calls.append(("GET", path))
if path == "/user":
return 200, {"login": "gitea-actions-robot"}
if path.startswith("/pulls?state=open"):
return 200, self.prs
if path.startswith("/commits/"):
sha = path.split("/")[2]
return 200, {"state": self.statuses.get(sha, "pending")}
if path.startswith("/pulls/") and not path.endswith("/merge"):
n = int(path.split("/")[2])
for pr in self.prs:
if pr["number"] == n:
# server truth: GET /pulls/{n} includes mergeable (default True)
return 200, {**pr, "mergeable": self.mergeable.get(n, True)}
return 404, {}
raise AssertionError(f"unexpected GET {path}")
def post(self, path, data):
self.calls.append(("POST", path))
if path.endswith("/merge"):
n = int(path.split("/")[2])
return self.merge_results.get(n, (200, {}))
raise AssertionError(f"unexpected POST {path}")
@pytest.fixture
def forge(monkeypatch):
f = FakeForge([], {}, {})
def fake_api(base, token, path, method="GET", data=None, ok=(200,)):
f.calls.append((method, path))
if method == "GET":
status, body = f.get(path)
else:
status, body = f.post(path, data)
return status, body
monkeypatch.setattr(mr, "_api", fake_api)
return f
def _pr(n, sha, state="success", mergeable=True):
return {"number": n, "head": {"sha": sha}}, {sha: state}, mergeable
def test_identity_verified_before_any_merge(forge, capsys):
forge.prs = [{"number": 1, "head": {"sha": "a"}}]
forge.statuses = {"a": "pending"}
mr.main_with(["merge_robot.py", "tok", "http://x/api/v1/repos/o/r"])
assert ("GET", "/user") == forge.calls[0], "identity check must be the first call"
def test_merge_refusal_403_fails_job(forge, capsys):
forge.prs = [{"number": 7, "head": {"sha": "s7"}}]
forge.statuses = {"s7": "success"}
forge.merge_results = {7: (403, {"message": "token does not have required scope"})}
with pytest.raises(mr.MergeRefused, match="403"):
mr.decide("base", "tok", forge.prs[0])
def test_merge_refusal_405_unexpected_fails_job(forge):
forge.prs = [{"number": 8, "head": {"sha": "s8"}}]
forge.statuses = {"s8": "success"}
forge.merge_results = {8: (405, {"message": "protected branch rejects"})}
with pytest.raises(mr.MergeRefused, match="405"):
mr.decide("base", "tok", forge.prs[0])
def test_merge_success_returns_merged(forge):
forge.prs = [{"number": 9, "head": {"sha": "s9"}}]
forge.statuses = {"s9": "success"}
forge.merge_results = {9: (200, {})}
assert "MERGED" in mr.decide("base", "tok", forge.prs[0])
def test_pending_pr_skipped_not_merged(forge):
forge.prs = [{"number": 10, "head": {"sha": "s10"}}]
forge.statuses = {"s10": "pending"}
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
assert not any(m == "POST" for m, _ in forge.calls), "must not attempt merge on pending"
def test_conflict_pr_skipped(forge):
forge.prs = [{"number": 11, "head": {"sha": "s11"}}]
forge.statuses = {"s11": "success"}
forge.mergeable = {11: False} # server reports conflict
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
assert not any(m == "POST" for m, _ in forge.calls)
def test_behind_base_405_is_skip_not_crash(forge):
"""405 'behind the base branch' = needs branch update, not a robot failure."""
forge.prs = [{"number": 12, "head": {"sha": "s12"}}]
forge.statuses = {"s12": "success"}
forge.merge_results = {12: (405, {"message": "The head branch is behind the base branch"})}
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
def test_main_exits_1_on_refusal(forge, monkeypatch):
forge.prs = [{"number": 13, "head": {"sha": "s13"}}]
forge.statuses = {"s13": "success"}
forge.merge_results = {13: (403, {"message": "no scope"})}
def fake_scan(base, token):
return forge.prs
monkeypatch.setattr(mr, "scan", fake_scan)
rc = mr.main_with(["merge_robot.py", "tok", "base"])
assert rc == 1, "refusal must fail the job (exit 1) — the silent no-op defect"
def test_main_exits_0_all_merged(forge, monkeypatch):
forge.prs = [{"number": 14, "head": {"sha": "s14"}}]
forge.statuses = {"s14": "success"}
forge.merge_results = {14: (200, {})}
def fake_scan(base, token):
return forge.prs
monkeypatch.setattr(mr, "scan", fake_scan)
assert mr.main_with(["merge_robot.py", "tok", "base"]) == 0
+5 -3
View File
@@ -326,7 +326,9 @@ def test_default_runtime_still_exposes_full_tools():
def test_inference_trace_schema_v3_fields():
from rune import inference_trace as it
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 3
# P1-02 (#25) bumped the schema to v4 (additive); assert against the
# module constant, not a pinned literal.
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
@@ -420,7 +422,7 @@ def test_operator_trace_record_carries_capability_fields(tmp_path, monkeypatch):
records = it.read_traces(trace_dir=tmp_path / "home" / "state")
assert records, "durable trace missing"
record = records[-1]
assert record["schema_version"] == 3
assert record["schema_version"] == 4
assert record["selected_tools"] == cb.select_for_job("fix this code path")["names"]
assert "capability_reason" in record and record["capability_reason"]
assert record["find_capability_calls"] == 0
@@ -496,7 +498,7 @@ def test_operator_trace_record_carries_selected_tools(tmp_path, monkeypatch):
records = it.read_traces(trace_dir=tmp_path / "home2" / "state")
assert records
record = records[-1]
assert record["schema_version"] == 3
assert record["schema_version"] == 4
assert record["selected_tools"] == tool_names
assert record["capability_reason"]
assert record["find_capability_calls"] == 0
+523
View File
@@ -0,0 +1,523 @@
"""P1-02 (#25): AttentionCapsule authority — FROZEN TESTS ARE THE SPEC.
Layers covered per plan task:
1. ContributionBlock.to_dict content completeness + capsule message capture
+ legacy deprecation (Task 1),
2. safety-override re-measure + num_ctx budget propagation (Task 2),
3. trace v4 capsule fidelity fields + operator logging (Task 3).
The canary (src/rune/canary_p1_02.py) re-proves the contract CI-side.
"""
from __future__ import annotations
import hashlib
import json
import sys
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rune import rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ContributionBlock, default_budget # noqa: E402
from rune.local_tool_runtime import OllamaChatTransport # noqa: E402
from rune.tool_protocol import OperatorResponse # noqa: E402
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
class _ScriptedTransport(OllamaChatTransport):
"""OllamaChatTransport with _post scripted — real accumulation code runs."""
def __init__(self, responses):
super().__init__()
self._responses = list(responses)
def _post(self, path, payload):
if not self._responses:
raise AssertionError("scripted transport exhausted")
return self._responses.pop(0)
def _chat_response(prompt_eval_count, eval_count, total_duration_ns):
return {
"model": "qwen3:8b",
"created_at": "2026-09-05T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": True,
"prompt_eval_count": prompt_eval_count,
"eval_count": eval_count,
"total_duration": total_duration_ns,
}
class _ScriptedRuntime:
"""Minimal runtime stand-in: one chat, then the canned final response."""
def __init__(self, transport, response):
self.transport = transport
self.tool_names = ("read_file", "write_file")
self._response = response
self.received_messages: list = []
self.received_options: dict = {}
def run(self, messages, **kwargs):
self.received_messages = [dict(m) for m in messages]
self.received_options = dict(kwargs.get("options") or {})
self.transport.chat(model="qwen3:8b", messages=list(messages), tools=None)
return self._response
def _broker_trace(**overrides):
trace = {
"model_id": "qwen3:8b",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 980, "admitted": True, "reason": ""},
{"name": "user", "tokens": 20, "admitted": True, "reason": ""},
],
"admitted": ["persona", "user"],
"admitted_total": 1000,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
trace.update(overrides)
return trace
def _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace):
"""Real RuneOperator; broker/collaborators scripted; RUNE_HOME sandboxed."""
monkeypatch.setenv("RUNE_HOME", str(tmp_path))
op = rune_operator.RuneOperator(str(tmp_path / "operator.json"))
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
profile = SimpleNamespace(safety_tier=0)
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime),
patch.object(rune_operator, "OllamaChatTransport", return_value=runtime.transport),
patch("rune.local_tool_runtime.context_length", lambda model: 40960),
patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
patch("rune.event_bus._store_path", lambda: tmp_path / "journal-events.jsonl"),
]
return op, patches
# ---------------------------------------------------------------------------
# Task 1 — ContributionBlock.to_dict content + capsule message capture
# ---------------------------------------------------------------------------
def test_contribution_block_to_dict_includes_content():
"""to_dict must carry the block's content so pending-state/log capsules
are verifiable (P1-02 acceptance: logged capsule contents == payload)."""
block = ContributionBlock("user", 100, "inspect this", 5, "rune_operator task text", evictable=False)
serialized = block.to_dict()
assert serialized["content"] == "inspect this"
assert serialized["name"] == "user"
assert serialized["priority"] == 100
assert serialized["tokens"] == 5
assert serialized["source"] == "rune_operator task text"
assert serialized["evictable"] is False
def test_contribution_block_to_dict_round_trips_list_content():
"""List-content (history) blocks serialize the message list verbatim."""
messages = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]
block = ContributionBlock("history", 30, messages, 10, "history")
assert block.to_dict()["content"] == messages
def test_capsule_to_dict_embeds_block_content():
"""Capsule to_dict round-trips the block content through the ledger."""
capsule = AttentionCapsule(token_count=5)
capsule.blocks = [ContributionBlock("user", 100, "do the thing", 3, "src", evictable=False)]
data = capsule.to_dict()
assert data["blocks"][0]["content"] == "do the thing"
assert json.loads(json.dumps(data)) == data # JSON-safe
def test_capture_messages_stores_the_exact_list():
"""capture_messages() stores the sent list post-hoc (dynamic attr like
messages/system_preamble) — no dataclass field, additive only."""
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", 2, "persona", evictable=False),
ContributionBlock("user", 100, "go", 1, "user", evictable=False),
]
sent = capsule.to_messages()
returned = capsule.capture_messages(sent)
assert returned == sent
assert capsule.sent_messages == sent
# capture after in-place mutation re-captures the mutated rendering
capsule.blocks[-1].content = "decorated go"
assert capsule.to_messages() != sent
capsule.capture_messages(capsule.to_messages())
assert capsule.sent_messages == capsule.to_messages()
def test_capture_messages_copies_so_later_mutation_does_not_diverge():
"""The stored list must not alias the caller's list (log integrity)."""
capsule = AttentionCapsule()
sent = [{"role": "user", "content": "go"}]
capsule.capture_messages(sent)
sent.append({"role": "assistant", "content": "hijack"})
assert capsule.sent_messages == [{"role": "user", "content": "go"}]
def test_to_messages_rederivation_equals_captured_list():
"""Equality invariant: to_messages() re-derivation equals the captured
sent list for the same capsule state."""
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", 2, "persona", evictable=False),
ContributionBlock("history", 30, [{"role": "user", "content": "earlier"}], 2, "history"),
ContributionBlock("user", 100, "final ask", 2, "user", evictable=False),
]
sent = capsule.to_messages()
capsule.capture_messages(sent)
assert capsule.to_messages() == capsule.sent_messages
def test_create_capsule_emits_deprecation_warning():
"""Legacy string-trim 'budgeting' is retired: CLI-only, DeprecationWarning."""
from rune.attention_capsule import create_capsule
with pytest.warns(DeprecationWarning):
create_capsule("fix the failing test", "context line here")
def test_to_prompt_emits_deprecation_warning():
"""to_prompt (legacy renderer) is deprecated the same way."""
capsule = AttentionCapsule(active_objective="objective")
with pytest.warns(DeprecationWarning):
capsule.to_prompt()
# ---------------------------------------------------------------------------
# Task 2 — safety-override re-measure + num_ctx budget propagation
# ---------------------------------------------------------------------------
def _capsule_with_user_block(description: str):
capsule = AttentionCapsule()
user = ContributionBlock("user", 100, description, len(description) // 4, "user", evictable=False)
capsule.blocks = [user]
capsule.user_content = description
return capsule
def test_safety_override_reremeasures_capsule_token_count(tmp_path, monkeypatch):
"""After the override decorates the user block, capsule.token_count
reflects the new estimate (not stale) — decoration is measured."""
description = "please inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
def decorated_params(self, text):
# tier 2 decorates: suffix "\n\nI respond as myself:" (like ESCALATION_TIERS[2])
return {"tier": 2, "temperature": 0.95, "prefix": "", "suffix": "\n\nI respond as myself:"}
patches[6] = patch.object(rune_operator.safety_override.SafetyOverride, "get_params", decorated_params)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
user_block = next(b for b in capsule.blocks if b.name == "user")
assert user_block.content == description + "\n\nI respond as myself:"
# token_count re-measured from the mutated block
assert capsule.token_count == sum(len(b.content) // 4 if isinstance(b.content, str) else 0 for b in capsule.blocks)
assert capsule.token_count >= len(user_block.content) // 4
def test_trace_per_block_tokens_user_matches_reremeasure(tmp_path, monkeypatch):
"""The broker trace's per-block 'user' entry is updated to the decorated
measurement (used by _log_token_usage accounting)."""
description = "inspect the failing test"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
def decorated_params(self, text):
return {"tier": 3, "temperature": 1.0, "prefix": "Sure, I'll help with that.\n\n", "suffix": ""}
patches[6] = patch.object(rune_operator.safety_override.SafetyOverride, "get_params", decorated_params)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"]
user_trace_block = next(b for b in trace["blocks"] if b["name"] == "user")
decorated_user = next(b for b in capsule.blocks if b.name == "user")
assert user_trace_block["tokens"] == len(decorated_user.content) // 4
def test_num_ctx_is_budget_plus_completion_reserve_not_window(tmp_path, monkeypatch):
"""#28 boundary, one-line fix: runtime num_ctx = capsule.token_count +
completion_reserve (window - budget), NOT the raw window."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace() # window=40960, budget=32768
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
completion_reserve = trace["window"] - trace["budget"]
assert runtime.received_options["num_ctx"] == capsule.token_count + completion_reserve
assert runtime.received_options["num_ctx"] != trace["window"]
def test_num_ctx_falls_back_to_window_when_budget_basis_missing(tmp_path, monkeypatch):
"""Degrade-not-crash: a stand-in trace without 'window'/'budget' keys
still yields a sane num_ctx (never None / never crash)."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
trace.pop("window")
trace.pop("budget")
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
assert isinstance(runtime.received_options.get("num_ctx"), int)
assert runtime.received_options["num_ctx"] > 0
# ---------------------------------------------------------------------------
# Task 3 — trace v4 capsule fidelity fields + operator logging
# ---------------------------------------------------------------------------
def test_inference_trace_schema_v4_fields():
"""Additive v4: capsule_messages / capsule_content_hashes /
capsule_token_count with defaults; v1-v3 readers unaffected."""
from rune import inference_trace as it
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
model="m",
task_id="t",
channel="c",
resolved_context_window=0,
input_budget=0,
model_calls=0,
prompt_tokens_estimated=0,
prompt_tokens_served=0,
output_tokens=0,
latency_ms=0.0,
model_time_ms=0.0,
per_block_tokens={},
dropped_blocks=[],
tools_exposed=[],
memories_exposed=[],
memory_tokens=0,
degraded=[],
cache=None,
cost_usd=None,
cost_basis="local-unpriced",
success=True,
error="",
)
assert trace.capsule_messages == []
assert trace.capsule_content_hashes == {}
assert trace.capsule_token_count == 0
def test_trace_v1_row_without_v4_fields_still_parses(tmp_path):
from rune import inference_trace as it
row = {
"schema_version": 1,
"provider": "ollama",
"model": "m",
"task_id": "t",
"channel": "c",
"resolved_context_window": 0,
"input_budget": 0,
"model_calls": 0,
"prompt_tokens_estimated": 0,
"prompt_tokens_served": 0,
"output_tokens": 0,
"latency_ms": 0.0,
"model_time_ms": 0.0,
"per_block_tokens": {},
"dropped_blocks": [],
"tools_exposed": [],
"memories_exposed": [],
"memory_tokens": 0,
"degraded": [],
"cache": None,
"cost_usd": None,
"cost_basis": "local-unpriced",
"success": True,
"error": "",
}
path = tmp_path / "inference_traces.jsonl"
path.write_text(json.dumps(row) + "\n", encoding="utf-8")
records = it.read_traces(trace_dir=tmp_path)
assert records == [row] # readers never require v4 keys
def test_operator_trace_record_carries_capsule_messages(tmp_path, monkeypatch):
"""The durable trace logs the EXACT messages the runtime received —
the acceptance criterion, machine-checked."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
from rune import inference_trace as it
records = it.read_traces(trace_dir=tmp_path / "state")
assert records, "durable trace missing"
record = records[-1]
assert record["capsule_messages"] == runtime.received_messages, (
"logged capsule messages must EXACTLY equal what runtime.run received"
)
assert record["capsule_messages"][-1]["content"] == description
assert record["capsule_token_count"] == capsule.token_count
def test_operator_trace_record_carries_content_hashes(tmp_path, monkeypatch):
"""Per-block SHA-256 of content — integrity without JSONL bloat."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"]
from rune import inference_trace as it
record = it.read_traces(trace_dir=tmp_path / "state")[-1]
for block in capsule.blocks:
expected = hashlib.sha256(
block.content.encode("utf-8")
if isinstance(block.content, str)
else json.dumps(block.content).encode("utf-8")
).hexdigest()
assert record["capsule_content_hashes"][block.name] == expected