Compare commits

..
Author SHA1 Message Date
Nick 15119ebb6e Merge remote-tracking branch 'gitea/master' into p1-03-memory-selection
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 32s
CI Pipeline / canary (pull_request) Failing after 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) Skipped
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 1s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / lint (push) Successful in 44s
CI Pipeline / canary (push) Failing after 37s
CI Pipeline / type-check (push) Successful in 33s
CI Pipeline / unit-tests (push) Failing after 44s
CI Pipeline / required-checks (push) Skipped
2026-09-07 21:01:24 +01:00
Nick 1de937d898 Merge remote-tracking branch 'gitea/master' into p1-03-memory-selection
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 3s
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Failing after 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
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 36s
CI Pipeline / canary (pull_request) Failing after 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 14s
CI Pipeline / required-checks (pull_request) Skipped
2026-09-07 17:15:23 +01:00
Nick 6b6ddd1a3d test(p1-03): seed canary trivial-check distractor; discharge record (#26)
Gate finding 8: the canary's trivial-task check ran on an EMPTY store —
the capsule-level zero-memory proof was vacuous. It now seeds a
high-importance fact distractor plus a personality row before
assembling; the trivial task must still produce zero memory blocks and
zero memory tokens.

Also lands the discharge record: docs updated (floor-in-every-mode
claim, known ranking bias, typed persona lane, write-time memory_type,
migration-time backfill) and the plan's Completion Record gains the
Discharge Record section mapping every gate finding to its commit and
red-first evidence.
2026-09-07 17:10:41 +01:00
Nick 3175ece37c refactor(p1-03): canary proves gate_consolidated retirement via AST (#26)
Gate finding 7 follow-up: gate_consolidated itself is deleted (it rode
into the finding-1 commit with the memory_store rewrite); this commit
fixes canary_query_memory's gate check, which inspected whichever
memory_store a sys.path shim resolved first (the DEPLOYED scripts copy,
in practice) — it now proves the retirement deterministically via an
AST parse of the repo copy. Suite and canary battery stay green.
2026-09-07 17:05:28 +01:00
Nick 29d03beb25 test(p1-03): strengthen vacuous selector + weak fallback assertions (#26)
Gate findings 5+6 (test-only):
- finding 5: the operator-level tests passed trivially on empty tmp
  stores ('block is None or ...'). They now seed the store and assert
  the positive branch — a real selector-sourced block with exact source,
  and the history test seeds a completed past task through the canonical
  ledger so the priority/label assertions are positive.
- finding 6: test_cached_recall_never_returns_flat_eight_tenths escaped
  via 'or source==consolidated'; it now asserts the calibrated recall
  shape (rrf/confidence/id present, relevance != 0.8) with shard routing
  disabled (it silently rerouted to an empty shard, vacating the old
  assertion), plus abstention + positive twins. The importance-fallback
  grep now covers ALL fallback slice patterns plus a behavioral twin
  (off-trigger high-importance neuron does not inject through the
  operator).
2026-09-07 17:01:19 +01:00
Nick ea5de21d2f fix(p1-03): neuron backfill runs at migration time only (#26)
Gate finding 4: ensure_memory_type_column ran an unindexed
UPDATE ... WHERE kind='neuron' scan on EVERY connect, and the selector
opens a fresh connection per turn — pure per-turn overhead. connect()
now invokes the migration only when the column is actually missing
(the ALTER stays idempotent); explicit ensure_memory_type_column()
keeps the frozen self-healing semantics (re-classifies unclassified
neuron rows, never touches explicit classifications).

Red-first evidence: a NULL marker row planted on an already-migrated
store was flipped back to 'associative' by a second connect — now the
per-connect scan is gone while explicit ensure() still self-heals.
2026-09-07 17:00:02 +01:00
Nick 70c20072fe feat(p1-03): typed persona lane + write-time memory_type classification (#26)
Gate finding 2: the typed persona source was dead in production — the
operator's single memory contributor calls select_for_task WITHOUT
types, so _persona_recall always returned []; and no production writer
ever set memory_type. The store source now always runs a SECOND typed
query (self/personality) merged into the candidates — still thresholded
inside the store's gate, deduplicated by entry id, may abstain (a
trivial task still gets zero persona rows). add_entry gains an additive
memory_type param (default None = unclassified, eligible everywhere)
plus a --memory-type store CLI flag wired through the dedupe gate;
wired where the writer genuinely knows the type: warm-router neuron
writer -> associative, sleep-time growth sync -> personality
(persona-targeted) / semantic.

Gate finding 3: _persona_recall ignored the selector's pinned db_path
(bare memory_store.connect() opened the module-default store). The pin
is now bound into sources that declare db_path at registration time;
both store-backed sources honor it.

Red-first evidence: no typed call in the live contributor path;
add_entry TypeError; persona block missing from the pinned-only store.
2026-09-07 16:55:10 +01:00
Nick 3f53d93525 fix(p1-03): recall applies the ONE floor in every mode (#26)
Gate finding 1: min_score was only enforced in recall's dense-only
branch — lexical-only and hybrid paths returned below-floor store rows,
and the selector's store_scored exemption then injected them. The floor
now applies in EVERY mode inside the store's calibrated gate (lexical
best-relevance check additive to the conjunctive FTS gate; hybrid gates
on the dense cosine, the scale min_score was calibrated against); the
selector docstring claim was rewritten to match reality.

Also documents the known ranking-scale mix (finding 9): lexical store
~4-6 vs dense/hybrid RRF ~0-0.13 vs self-scored 0-1 ranked raw together
— abstention unaffected, per-source rank normalization deliberately
deferred (renormalizing the calibrated scales risked false abstentions).

Red-first evidence: lexical-only rows returned at floor 5.5; hybrid
cos~0.37 row injected at floor 0.42 — both abstain after the fix, with
above-floor positive controls.
2026-09-07 16:53:30 +01:00
Nick cbb15fe788 docs(p1-03): plan completion record (#26) 2026-09-07 12:39:55 +01:00
Nick 57c0fafa8f fix(p1-03): prior canary trace-version pins follow the schema constant (#26) 2026-09-07 12:34:41 +01:00
Nick 7101087afa feat(p1-03): memory budget, trace v5, canary (#26) 2026-09-07 12:24:51 +01:00
Nick 32de159e3c feat(p1-03): single memory contributor, TaskState supremacy (#26) 2026-09-07 12:00:35 +01:00
Nick ff04888508 feat(p1-03): selector sources unified, bypasses removed (#26) 2026-09-07 11:44:11 +01:00
Nick f998768622 feat(p1-03): MemorySelector with threshold abstention (#26) 2026-09-07 11:08:26 +01:00
Nick fde27d73f5 feat(p1-03): memory_type column + typed recall filter (#26) 2026-09-07 10:55:37 +01:00
34 changed files with 3636 additions and 2442 deletions

No files matched your search

+13 -34
View File
@@ -14,25 +14,10 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Create lint environment from lock
- name: Create lint environment from manifest
run: |
# #150: deps come ONLY from the pinned lock; the project itself is
# installed with --no-deps so nothing floats outside requirements-ci.txt.
python3 -m venv .ci-venv
.ci-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
.ci-venv/bin/pip install --disable-pip-version-check -e . --no-deps
- name: Dependency lock drift ratchet
run: |
# #150: fail the job if requirements-ci.txt no longer matches
# pyproject.toml. uv is not on runners; install it pinned into the
# lint venv (tool-only, not a project dependency).
.ci-venv/bin/pip install --disable-pip-version-check uv==0.10.11
# #150 gate review: the checker must resolve the PINNED uv (in
# .ci-venv/bin), not a stray /usr/bin/uv with a different resolver
# version — export the venv first; the checker also prefers the
# interpreter-adjacent uv as a second line of defense.
export PATH="$PWD/.ci-venv/bin:$PATH"
.ci-venv/bin/python scripts/check_dep_lock.py
.ci-venv/bin/pip install --disable-pip-version-check -e ".[dev]"
- name: Reject syntax and undefined-name errors
run: |
BASE=$(python3 -c 'import json, os; event=json.load(open(os.environ["GITHUB_EVENT_PATH"])); print(event.get("pull_request", {}).get("base", {}).get("sha") or event.get("before") or "")')
@@ -61,12 +46,10 @@ jobs:
steps:
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Create test environment from lock
- name: Create test environment from manifest
run: |
# #150: deps come ONLY from the pinned lock.
python3 -m venv .test-venv
.test-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
.test-venv/bin/pip install --disable-pip-version-check -e . --no-deps
.test-venv/bin/pip install --disable-pip-version-check -e ".[dev,memory]"
- name: Test local tools and interstitial message objects
run: |
PYTHONPATH=src:. .test-venv/bin/python -m pytest tests/ -q
@@ -79,14 +62,14 @@ jobs:
steps:
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Create canary environment from lock
- name: Create canary environment
run: |
# #150: the old ad-hoc textual pre-install + '|| true' fallback is
# gone — the lock pins textual==8.2.8 and textual-autocomplete==4.0.6
# (exactly what the canary needed), so there is no silent escape hatch.
python3 -m venv .canary-venv
.canary-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
.canary-venv/bin/pip install --disable-pip-version-check -e . --no-deps
.canary-venv/bin/pip install --disable-pip-version-check textual==8.2.8 textual-autocomplete==4.0.6 sqlite-vec 2>/dev/null || true
- name: Create canary environment from manifest
run: |
python3 -m venv .canary-venv
.canary-venv/bin/pip install --disable-pip-version-check -e ".[dev,memory]"
- name: Run canary suite
run: |
FAILED=0
@@ -108,6 +91,7 @@ jobs:
src/rune/canary_p0_05.py \
src/rune/canary_p0_07.py \
src/rune/canary_p1_02.py \
src/rune/canary_p1_03.py \
src/rune/canary_p1_07.py \
src/rune/canary_p1_06.py \
src/rune/canary_p1_01.py \
@@ -195,15 +179,10 @@ jobs:
runs-on: rune-ci-desktop
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Create type-check environment from lock
- name: Create type-check environment from manifest
run: |
# #150 gate review: the protected type-check toolchain previously
# installed floating ".[dev,memory]" — unreproducible. Same lock-only
# pattern as every other job: deps from the pinned file, project with
# --no-deps so nothing floats outside requirements-ci.txt.
python3 -m venv .type-venv
.type-venv/bin/pip install --disable-pip-version-check -r requirements-ci.txt
.type-venv/bin/pip install --disable-pip-version-check -e . --no-deps
.type-venv/bin/pip install --disable-pip-version-check -e ".[dev,memory]"
- name: Type-check with ratchet
run: |
.type-venv/bin/python scripts/type_check_ratchet.py
+1 -5
View File
@@ -26,13 +26,9 @@ jobs:
merge-green:
runs-on: rune-ci-desktop
steps:
- name: "Checkout repo — job never checked out, scripts/merge_robot.py missing, exit 2 since #211"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Merge PRs whose protected checks are green (fail-loud)
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }} # conductor token; GITEA_TOKEN is reserved (auto token, read-only)
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
SERVER: ${{ gitea.server_url }}
run: |
python3 scripts/merge_robot.py "$GITEA_TOKEN" "$SERVER/api/v1/repos/conductor/rune"
@@ -1,46 +0,0 @@
name: Research backlog sweep
# One-time (repeatable, idempotent) research-pending backfill for the standing
# backlog (#204 second slice). The gate labels only on ready EVENTS; issues
# that predate it were never labeled → never researched. This workflow runs
# scripts/research_backfill_sweep.py with the gate's own semantics:
# eligible = open, not blocked, not already research-pending,
# NO existing 4-phase research round (already-done stays done),
# all Depends-on server-verified closed.
# Dry-run by default (comments the eligible set on nothing, prints to log);
# workflow_dispatch with apply=true performs the labeling. Each application
# fires the research stage (label event) — so apply is the ON switch for
# backlog research.
on:
workflow_dispatch:
inputs:
apply:
description: "Apply labels (omit = dry-run report only)"
required: false
default: "false"
permissions:
issues: write
contents: read
jobs:
sweep:
runs-on: rune-ci-desktop
steps:
- name: Check out master
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Run sweep
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
SERVER: ${{ gitea.server_url }}
APPLY: ${{ gitea.event.inputs.apply || 'false' }}
run: |
ARGS="sweep --base $SERVER/api/v1/repos/conductor/rune --token $GITEA_TOKEN"
if [ "$APPLY" = "true" ]; then
ARGS="$ARGS --apply"
fi
python3 scripts/research_backfill_sweep.py $ARGS
@@ -1,160 +0,0 @@
name: Research stage (headless)
# #204 second slice — RESEARCH STRUCTURALIZATION (Nick's directive, 2026-09-07):
# research is INITIATED by the forge, not by controller attention.
#
# When an issue gains `research-pending` (ready-event or backfill sweep), this
# stage runs a headless mimo agent that performs the RESEARCH ROUND V3 —
# four phases (INWARD / OUTWARD / INNOVATION / SYNTHESIS) — and posts ONE
# issue comment carrying all four phase markers, which the gate's
# research-clear job detects and clears the label (issue becomes startable).
#
# Filing contract (Nick): the full round is also saved as a research file
# (research/issue-<n>-research.md) committed to master by the agent, so
# findings are version-controlled and greppable — comments notify, files
# endure. SYNTHESIS must end in concrete build instructions: the stage's
# output IS the prompt that informs the build.
#
# Fail-loud: infrastructure errors fail the job; only a genuine empty agent
# output gets the fallback comment.
on:
issues:
types: [labeled]
permissions:
issues: write
contents: write
concurrency:
group: research-stage-${{ gitea.event.issue.number }}
cancel-in-progress: false
jobs:
research:
runs-on: rune-ci-desktop
if: gitea.event.label.name == 'research-pending'
steps:
- name: Check out master (research reads the codebase, not a PR)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Run headless research round (4 phases)
env:
MIMOCODE_AUTO_APPROVE_DELETE: "1"
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS: "1"
HOME: /var/lib/gitea-desktop-runner
XDG_CONFIG_HOME: /home/nick/.config
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
ISSUE_TITLE: ${{ gitea.event.issue.title }}
ISSUE_BODY: ${{ gitea.event.issue.body }}
run: |
# Prompt = governed repo template (prompts/research-round.md) with
# placeholders substituted. The template is the TUI-callback seam:
# future L3 (Rune TUI) owns/overrides it via reviewed diffs.
python3 - <<'PY'
import os
tpl = open("prompts/research-round.md").read()
out = (tpl.replace("{{ISSUE_NUMBER}}", os.environ["ISSUE_NUMBER"])
.replace("{{ISSUE_TITLE}}", os.environ["ISSUE_TITLE"])
.replace("{{ISSUE_BODY}}", os.environ["ISSUE_BODY"] or "(empty body)"))
open("/tmp/research_prompt.txt", "w").write(out)
print(f"prompt built: {len(out)} chars from prompts/research-round.md")
PY
/var/lib/gitea-desktop-runner/bin/mimo run \
-m "Verboo Code/glm-5.3-flash" \
--dangerously-skip-permissions \
--format json \
"$(cat /tmp/research_prompt.txt)" \
> /tmp/research_events.jsonl 2>/tmp/research_err.log
# Extract final assistant text (fail-loud: no swallow)
python3 - <<'PY'
import json
text = ""
try:
for line in open("/tmp/research_events.jsonl"):
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
for part in ev["message"]["content"]:
if part.get("type") == "text":
text = part.get("text", text)
except FileNotFoundError:
pass
if not text.strip():
raise SystemExit("research stage: agent produced NO text — infrastructure or model failure (fail-loud)")
open("/tmp/research_round.md", "w").write(text)
PY
- name: File research as comment + versioned research file
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
SERVER: ${{ gitea.server_url }}
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
run: |
python3 - <<'PY'
import json, os, subprocess, urllib.request
token = os.environ["GITEA_TOKEN"]
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
n = os.environ["ISSUE_NUMBER"]
round_md = open("/tmp/research_round.md").read().strip()
# 1. Comment (the marker the research-clear job keys on)
body = (f"🤖 **Headless research round (4-phase, #204 second slice)**\n\n{round_md}\n\n"
"<details><summary>Filed</summary>This round is also filed as "
f"research/issue-{n}-research.md on master (version-controlled, greppable). "
"research-pending will clear via the gate.</details>")
req = urllib.request.Request(
f"{base}/issues/{n}/comments",
data=json.dumps({"body": body}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
print(f"research comment posted: {r.status}")
# 2. Research file committed straight to master by the robot identity
subprocess.run(["git", "config", "user.name", "conductor"], check=True)
subprocess.run(["git", "config", "user.email", "nick@thingscouldgetdicey.online"], check=True)
subprocess.run(["git", "remote", "set-url", "origin",
f"https://conductor:{token}@gitea.thingscouldgetdicey.online/conductor/rune.git"], check=True)
subprocess.run(["git", "fetch", "origin", "master"], check=True)
subprocess.run(["git", "checkout", "-B", f"research-file-{n}", "origin/master"], check=True)
path = f"research/issue-{n}-research.md"
os.makedirs("research", exist_ok=True)
header = (f"Research round for #{n} (4-phase, agent-generated, "
"filed by research stage per #204; comment on the issue is the "
"research-clear trigger)\n\n---\n\n")
open(path, "w").write(header + round_md + "\n")
subprocess.run(["git", "add", path], check=True)
subprocess.run(["git", "commit", "-m",
f"docs(research): file 4-phase research round for #{n} (headless stage)"], check=True)
subprocess.run(["git", "push", "origin", f"HEAD:research-file-{n}", "-f"], check=True)
# Merge the one-file branch straight into master (contents:write scope)
mr = urllib.request.Request(
f"{base}/pulls",
data=json.dumps({"title": f"research file for #{n}",
"head": f"research-file-{n}", "base": "master",
"body": "Auto-filed research round (research stage)."}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(mr, timeout=30) as r:
pr = json.loads(r.read())
merge = urllib.request.Request(
f"{base}/pulls/{pr['number']}/merge",
data=json.dumps({"Do": "merge"}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(merge, timeout=30) as r:
print(f"research file merged to master via PR #{pr['number']}: {r.status}")
except urllib.error.HTTPError as e:
# PR may already exist from a re-run; merge it directly
print(f"PR create/merge skipped: {e.code} {e.read()[:200]}")
PY
-115
View File
@@ -1,115 +0,0 @@
name: Spec gate (headless)
# First slice of #204: agent cognition as a scoped, event-triggered Actions
# stage. A PR opens → this job runs a single self-contained review prompt
# (black-box doctrine per docs/adversarial-review.md — the reviewer answers
# the mandatory question BEFORE reading the implementation) → posts a
# Conventional-Comments verdict as a NON-BLOCKING PR comment.
#
# Deterministic floors underneath (untouched): required checks still gate the
# merge; this stage adds judgment, never replaces the floors.
on:
pull_request:
types: [opened, synchronize]
branches: [master]
permissions:
issues: write
pull_requests: write
contents: read
jobs:
spec-gate:
runs-on: rune-ci-desktop
# Skip draft PRs and our own automation PRs (bump PRs have no reviewable logic)
if: ${{ !startsWith(gitea.event.pull_request.title, 'chore(release)') }}
steps:
- name: Check out PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Run headless spec-gate review
env:
MIMOCODE_AUTO_APPROVE_DELETE: "1"
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS: "1"
HOME: /var/lib/gitea-desktop-runner
XDG_CONFIG_HOME: /home/nick/.config
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
run: |
BASE_SHA="$(git merge-base origin/master HEAD)"
{
echo "## Spec-gate review request"
echo ""
echo "You are the INDEPENDENT spec-compliance reviewer for this PR."
echo ""
echo "MANDATORY FIRST STEP (docs/adversarial-review.md): answer, in writing,"
echo "'What incorrect implementation could still pass these tests?' BEFORE"
echo "reading the implementation diff. Enumerate the wrong implementations"
echo "the existing tests would not catch."
echo ""
echo "Then: read the diff (git diff $BASE_SHA..HEAD), the changed files, and"
echo "any plan files under plans/ referenced in the PR body. Check:"
echo "1. Acceptance criteria (from the issue) — PASS/FAIL per criterion with file:line evidence."
echo "2. Deviation honesty — are recorded deviations sound? Any weakening of acceptance?"
echo "3. Test honesty — behavior-asserting or implementation-coupled? Vacuous assertions?"
echo "4. Silent failure — every except re-raises/logs/spools?"
echo ""
echo "Output format (Conventional Comments):"
echo "- Verdict line: 'Verdict: PASS' or 'Verdict: PASS-with-findings' or 'Verdict: Changes-required'"
echo "- Findings as: [BLOCKER]/[MAJOR]/[MINOR] — description (file:line)"
echo "- The mandatory-question answer, explicitly labeled."
echo "Be adversarial. Be specific. Under 500 words."
} > /tmp/spec_gate_prompt.txt
/var/lib/gitea-desktop-runner/bin/mimo run \
-m "Verboo Code/glm-5.3-flash" \
--dangerously-skip-permissions \
--format json \
"$(cat /tmp/spec_gate_prompt.txt)" \
> /tmp/spec_gate_events.jsonl 2>/tmp/spec_gate_err.log
# Extract the final assistant text from the JSONL event stream
python3 - <<'PY'
import json
text = ""
try:
for line in open("/tmp/spec_gate_events.jsonl"):
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
for part in ev["message"]["content"]:
if part.get("type") == "text":
text = part.get("text", text)
except FileNotFoundError:
pass
open("/tmp/spec_gate_verdict.md", "w").write(text or "Spec-gate agent produced no text output — check runner logs.")
PY
- name: Post verdict as non-blocking PR comment
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }} # GITEA_TOKEN is reserved (read-only auto token)
SERVER: ${{ gitea.server_url }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
python3 - <<'PY'
import json, os, urllib.request
token = os.environ["GITEA_TOKEN"]
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
pr = os.environ["PR_NUMBER"]
body = open("/tmp/spec_gate_verdict.md").read().strip()
body = ("🤖 **Headless spec gate (non-blocking, #204 first slice)**\n\n" + body +
"\n\n<details><summary>Deterministic floors still govern the merge</summary>\n\n"
"This comment is advisory. Required checks (lint, tests, canaries, "
"type-check, coverage, dependency lock, impact gate) remain the merge "
"authority per docs/adversarial-review.md proportionality.</details>")
req = urllib.request.Request(
f"{base}/issues/{pr}/comments",
data=json.dumps({"body": body}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
print(f"verdict posted: {r.status}")
PY
-26
View File
@@ -1413,32 +1413,6 @@ Run tests:
python3 -m pytest tests/
```
### CI dependency lock
CI installs dependencies from the committed, fully-pinned
`requirements-ci.txt` (issue #150) — never from the floating floor pins in
`pyproject.toml`. Same commit + same lock = same CI environment.
After ANY dependency edit in `pyproject.toml`, regenerate the lock and
commit the result:
```bash
uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
python3 scripts/check_dep_lock.py # must print "in sync" before committing
```
Why `--python-version 3.11`: the lock must resolve for the CI runners'
interpreter (not your dev machine's), so environment markers pick the same
wheels CI will get. `uv` is a local dev convenience only — the lock is plain
pip format, and runners install it with stock pip plus
`pip install -e . --no-deps` (`--no-deps` so the project's own dependencies
come ONLY from the pinned file; no floating escape hatch).
The lint job runs `scripts/check_dep_lock.py` as a drift ratchet: if
`requirements-ci.txt` no longer matches `pyproject.toml`, CI fails with the
regeneration command — the lock is reviewed and re-committed, never
auto-regenerated.
---
## Configuration
+139
View File
@@ -0,0 +1,139 @@
# Memory Selection (P1-03, #26)
One memory-selection API owned by `ContextBroker.memory_selector` (authority
map `config/v1_authority_map.json`, domain `memory_selection`, v1_owner
`ContextBroker.memory_selector`), with true abstention and one relevance
floor.
## The one API
`MemorySelector` (`src/rune/memory_selector.py`) —
`select_for_task(task_description, types=None, budget=N) -> SelectionResult`
(mirrors `CapabilityBroker.select_for_job`). `SelectionResult` carries
`blocks`, `candidates_evaluated`, `selected_count`, `abstained`,
`types_queried`; the irrelevant-memory rate is computable per run as
`(candidates_evaluated - selected_count) / candidates_evaluated` when
candidates > 0.
The operator registers exactly ONE memory contributor (`memory`) that
delegates to `broker.memory_selector.select_for_task`. The six pre-P1-03
parallel contributors (memory, persona_memories, consolidated, neurons,
graphs, abstraction) were replaced — their sources now live inside the
selector. No parallel authority path remains.
## One relevance floor — true abstention
Every source competes under THE floor: `memory_store.RECALL_MIN_SCORE`
(0.42). The store source passes it into `memory_store.recall`'s calibrated
QPP gate — which applies it in EVERY recall mode (lexical-only, dense-only,
hybrid; the numeric check is additive to the conjunctive FTS gate) — so the
selector's `store_scored` exemption is sound. Self-scored sources are
floored in the selector.
**Known ranking bias (documented, deferred):** the floor gates each source
on its own calibrated scale (lexical store ~4-6, dense/hybrid store RRF
~0-0.13, self-scored 0-1), but merged candidates are ranked by raw score —
a lexical-mode store row outranks an equally-relevant self-scored row.
Abstention is unaffected; only candidate ORDER is biased. Per-source rank
normalization was deliberately deferred (renormalizing the store's
calibrated scales risked false abstentions).
- A trivial task ("what time is it") legitimately receives ZERO recalled
memories — `abstained=True`, zero blocks. Abstention is the safe default.
- Below-floor candidates are COUNTED but never selected — measurement, not
injection.
- The top-5-importance neuron fallback (old `rune_operator` neurons
contributor) is REMOVED: importance alone never injects.
- The `gate_consolidated` flat-0.8 shortcut inside `cached_recall` is
REMOVED — and the now-dead `gate_consolidated` function itself is deleted
from `memory_store` (discharge): consolidated knowledge competes under the
same floor through the selector's dedicated source.
## Selector sources
Registered in execution order (`DEFAULT_SOURCES`):
| Source | Reads | Gate |
| --- | --- | --- |
| `store` | `memory_store.recall` (typed) | store's calibrated gate (same constant) |
| `neurons` | `warm_neuron_store.list_neurons` trigger scan | lexical overlap ≥ floor; NO importance fallback |
| `graphs` | `graph_memory.query` BFS (read-only) | lexical overlap ≥ floor |
| `abstraction` | `abstraction` facts LIKE scan | lexical overlap ≥ floor |
| `consolidated` | `MemoryConsolidation` knowledge items | lexical overlap ≥ floor (flat-0.8 bypass removed) |
| `persona` | typed `self`/`personality` recall keyed on the ACTIVE task | store's calibrated gate |
Fail-closed everywhere: a failing source is skipped + logged (never a
raise, never a fallback injection); a selector unavailable → the
contributor abstains (zero memory blocks).
## Memory-type targeting
`entries.memory_type` (additive `ALTER TABLE`, backward compatible; values
`episodic|semantic|procedural|self|personality|associative|NULL`).
Backfill classifies ONLY `kind='neuron'` rows to `associative`, and the
backfill runs at MIGRATION time (when the column is first added) — NOT on
every connect, whose per-connect unindexed UPDATE scan the selector's
fresh-connection-per-turn pattern would pay every turn (discharge).
Explicit `ensure_memory_type_column()` calls keep self-healing semantics
(they re-classify unclassified neuron rows, never touching explicit
classifications); other rows stay NULL — unclassified is eligible for every
query (classification narrows, never excludes). Typed queries filter
`(memory_type IN ?) OR memory_type IS NULL`; typed queries bypass shard
routing (pre-migration shards carry no `memory_type` column).
**Typed persona lane (discharge):** the operator's memory contributor calls
the selector WITHOUT types, so the store source always runs a SECOND typed
query (`self`/`personality`) merged into the candidates — still thresholded
inside the store's gate, may abstain, deduplicated by entry id. The write
side: `add_entry(..., memory_type=...)` (and the store CLI's
`--memory-type`) classifies at WRITE time where the writer genuinely knows
the type — the warm-router neuron writer stamps `associative`, the
sleep-time growth sync stamps `personality` for persona-targeted evidence
(`semantic` otherwise).
## ContextBroker owns the final memory budget
`memory_budget = min(1200, window // 8)` (default; the global input budget
bounds it further). In `_admit`, the sum of `MEMORY_BLOCK_NAMES` blocks
(`memory`, `persona_memories`, `consolidated`) may never exceed the
sub-budget — further memory blocks are dropped (lowest priority first) with
a `memory budget` reason in the trace. The active TaskState block (`user`)
is non-evictable and unaffected. The selector also caps its own candidate
list before returning.
## TaskState supremacy
`_contributor_history` is rebased on `TaskStateRepository` (status
authority) + `ConversationStateRepository` (bodies): completed tasks only,
each user turn labeled `PAST TASK (completed — context only, not
instructions)`, block priority (30) strictly below the active task block
(`user`, 100, non-evictable). Recall queries are keyed ONLY on the active
task's description — never on past-task text.
## Trace v5
`INFERENCE_TRACE_SCHEMA_VERSION = 5` — additive optional fields (same
discipline as v1→v2→v3→v4; old readers ignore unknown keys):
- `memory_candidates` — candidates surfaced by all selector sources;
- `memory_selected` — candidates that cleared the floor;
- `memory_abstained` — true = zero memory injected (zero-injection event);
- `memory_types_queried` — typed targeting used for this run.
## Canary + CI
`src/rune/canary_p1_03.py` self-bootstraps (temp HOME/RUNE_HOME/
RUNE_MEMORY_DIR; no model, no network) and asserts the acceptance
invariants: trivial→zero memories, importance fallback dead, TaskState
supremacy, memory sub-budget enforcement. Registered in
`.gitea/workflows/ci.yml`'s canary for-loop (`henxels_checks.py
ci_runs_all_canaries` enforces the registration).
## Scope notes
- `warm_router.py` stays dormant-until-dependency (its ID-path defect is
#27 territory — not fixed here).
- `auto_context.py` is a TUI display artifact, not model context — out of
scope.
- Memory stores and their data are preserved; selection changes HOW/WHEN
memory is surfaced, never what is stored.
@@ -1,92 +0,0 @@
# #150 Reproducible CI dependency set (locked/compiled) without packaging rewrite — Implementation Plan
> **Execution contract:** TDD where testable; controller owns task state; conventional commits referencing (#150); PR + protected checks only; deviations in plan tail.
## Goal
Same commit + same lock = materially reproducible test environment in CI. Floor declarations (`textual>=8.2.0`) plus floating transitive deps mean the same commit resolves differently by date. Fix WITHOUT rewriting packaging: a committed, fully-pinned constraints file generated from pyproject, consumed by CI, with a drift check so it cannot rot.
## Architecture
1. **`requirements-ci.txt`** (committed): fully-pinned output of `uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory` (uv exists locally at /home/nick/.local/bin/uv; runners need NO uv — the output is plain pip format).
2. **CI change (`.gitea/workflows/ci.yml`)**: each venv step becomes:
`pip install --disable-pip-version-check -r requirements-ci.txt` then `pip install --disable-pip-version-check -e . --no-deps` (project itself from the checkout, deps pinned). The canary step's ad-hoc `textual==8.2.8 textual-autocomplete==4.0.6` pre-install collapses into the same pinned file (the `|| true` fallback dies — the pins are now guaranteed).
3. **Drift ratchet** (`scripts/check_dep_lock.py` + CI job step): regenerate the compile into a temp file; if it differs from the committed `requirements-ci.txt` → fail with "run: uv pip compile ... and commit". This makes the lock a ratchet, not a one-off. Runs in the existing lint job (fast, no network needed beyond index metadata — use `uv pip compile --no-annotate --generate-hashes` OFF; hashes add noise, skip).
- Local generation command documented in the script header + README dev section.
4. **Runner reality check:** CI currently creates `.ci-venv`, `.test-venv`, `.canary-venv` with `pip install -e ".[dev,memory]"` — all three switch to the pinned file. Python version pinning stays as-is (3.11 on runners via /var/lib/gitea-desktop-runner/py311 parity; `pythonVersion` in pyrightconfig unchanged). The compile must therefore be generated against Python 3.11 markers locally (`uv pip compile --python-version 3.11`) so markers resolve identically to runner reality.
## Design Decisions (settled)
1. `uv pip compile` output (plain requirements.txt) over `uv.lock`/`uv sync`: zero new tooling on runners, auditable diff-able file, no packaging rewrite. uv is a LOCAL dev convenience only.
2. `--python-version 3.11` at compile time: the lock must match the RUNNER's interpreter, not the dev machine's 3.14.
3. Drift check fails CI rather than auto-regenerating: reproducibility means the lock is reviewed, not silently mutated.
4. `--no-deps` on the editable install: the project's own deps come ONLY from the pinned file — no floating escape hatch.
5. Transitive pins are committed in full (that IS the point); regeneration is the documented upgrade path.
## Tasks
### Task 1 — Generate + verify the lock
Run `uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11` (header comment with the exact command + date); verify `pip install --dry-run -r requirements-ci.txt` resolves; write `scripts/check_dep_lock.py` (regenerate-to-temp + diff + clear failure message) with a unit test for its diff/exit logic (fixture files). Commit `feat(ci): pinned CI dependency lock (#150)`.
### Task 2 — CI consumes the lock
Edit ci.yml venv steps to pinned-file installs + `--no-deps` editable; add drift-check step to the lint job; YAML-parse verify; local smoke: create a venv, install from the file, run the full suite (604+84 must pass — this PROVES the pins resolve to a working env). Commit `feat(ci): install from pinned lock + drift ratchet (#150)`.
### Task 3 — Docs + henxel
README dev section: how to regenerate after dependency edits; note in the drift-check script header. Commit `docs(ci): dependency lock workflow (#150)`.
## Acceptance (from #150)
- Same commit + same lock = same resolution ✓ (pinned file, drift ratchet)
- No packaging rewrite ✓ (pyproject untouched; uv local-only)
- CI green on the pinned set ✓ (Task 2 full-suite smoke + protected CI run)
## Pipeline requirements checklist (from #150)
- [ ] TDD for the checker logic
- [ ] `ruff check`/`format --check` clean
- [ ] Spec gate PASS; quality gate Approved
- [ ] Full suite green under the PINNED install (the real acceptance)
- [ ] Existing canaries green
- [ ] Closed with formatted comment + PR ref
## Completion Record
(status: DONE — branch ci-dependency-lock @ c1dae4a + docs commit; not pushed)
- Task 1 ✓ `feat(ci): pinned CI dependency lock (#150)` — requirements-ci.txt (37 pins, uv 0.10.11, py3.11 markers), scripts/check_dep_lock.py (TDD: 10 unit tests, fixture files, no network), `pip install --dry-run -r requirements-ci.txt` resolves exit 0.
- Task 2 ✓ `feat(ci): install from pinned lock + drift ratchet (#150)` — all three venvs install from the lock + `-e . --no-deps`; canary ad-hoc pre-install + `|| true` deleted; drift ratchet step in lint job. YAML-parse verified (ci.yml OK).
- Task 3 ✓ `docs(ci): dependency lock workflow (#150)` — README Development → "CI dependency lock" (regen command, why 3.11, why --no-deps); script header documents the same.
- Kill test (the acceptance): fresh `.venv-killtest` on **Python 3.11.2** (runner-parity interpreter) → `pip install -r requirements-ci.txt` → `pip install -e . --no-deps` → FULL suite **614 passed + 84 subtests, green**. All 23 canaries also run green under the kill-test venv. Venv deleted after.
- Verification battery: ruff format/check on added .py files PASS; pyright ratchet PASS (117/407 vs baseline 120/465 — improved, not increased); henxels_checks.py exit 0 (canary list intact); all workflows YAML-parsed.
## Completion Record (cont.) — Gate-Review Discharge (2026-09-07, branch ci-dependency-lock)
(status: DONE — 6 discharge commits on top of 1ce64e4; not pushed, no rebase)
Six gate-review findings discharged red-first. Final battery: full suite **619 passed + 84 subtests** (614 baseline + 5 new tests), pyright ratchet **117e/407w = baseline (PASS, not increased)**, ruff format/check clean on touched files, henxels exit 0, ci.yml yaml-parses after every edit.
- **F1 [MAJOR] pinned-uv PATH defect** → `8568e57` `fix(ci): drift checker resolves the pinned uv, not a stray PATH uv (#150)`. RED evidence: two new tests (`test_uv_binary_prefers_interpreter_adjacent_uv` with PATH stripped + `test_uv_binary_prefers_interpreter_adjacent_over_path_uv` with a decoy uv on PATH) both FAILED against the old `shutil.which`-first resolver order (AssertionError, suite 2 failed/10 passed); GREEN after `uv_binary()` prefers `Path(sys.executable).parent / 'uv'`. Both ends fixed: ci.yml drift step now `export PATH="$PWD/.ci-venv/bin:$PATH"` before the checker; checker prefers interpreter-adjacent uv.
- **F2 silent skip of non-== lines** → `b7aa770` `fix(ci): lock checker fails loudly on non-pinned requirement lines (#150)`. RED evidence: `test_lock_requirements_rejects_floor_specs` (fixture `rune>=0.49.0`) and `test_lock_requirements_rejects_unpinned_editable_and_wildcard` FAILED against skip behavior (AssertionError "must exit 1, not be skipped"); GREEN after options become the only tolerated non-pin lines and any other non-pinned form exits 1 with actionable stderr (offending line + regen command). Pre-existing skip-options test updated to the tightened contract; real 37-pin lock still parses clean, checker exit 0.
- **F3 missing-uv raw traceback** → `084778d` `fix(ci): actionable message when uv is missing; suppress compile stdout (#150)`. RED evidence: `test_main_missing_uv_is_actionable_not_traceback` FAILED with raw `FileNotFoundError` (subprocess.py:1990); GREEN after `run_uv_compile` catches it (sentinel -1) and main() prints the house pattern (what to install: `pip install uv==0.10.11` into .ci-venv; what to run: PATH export; local install doc) and exits 1.
- **F5 uv resolution listing on stdout on success** → same commit `084778d` (shared function, inseparable). RED evidence: live checker run on the real lock printed uv's full 74-line resolution listing ("Resolved 37 packages…") before the fix; after the fix stdout/stderr are captured and printed only on compile failure (PATH-stripped real smoke run: success prints only the one-line in-sync message).
- **F4 marker-only drift + direct-URL lines undisclosed** → `d3c7039` `docs(ci): disclose drift-checker limitations in module docstring (#150)`. Docs-only, no behavior change: module docstring now discloses both limitations (marker-only drift invisible to the pin diff; direct-URL lines fail loudly rather than being compared). RED evidence: the pre-existing docstring lacked the disclosure (the finding itself); checker behavior/tests unchanged.
- **F6 type-check job floating extras** → `b24b6e3` `fix(ci): type-check installs from the pinned lock, not floating extras (#150)`. Config-only, pre-existing. RED evidence: ci.yml:196 installed floating `".[dev,memory]"` (gate finding); GREEN with the lock-only pattern (`pip install -r requirements-ci.txt` + `-e . --no-deps`), matching the other three jobs. `yaml.safe_load` verified after the edit; no active floating install remains in any workflow.
- `48d2cd8` `style(ci): ruff format check_dep_lock.py (#150)` — quote-style-only format nit caught by the close-out battery.
Deviation: F5 shipped inside F3's commit (same `run_uv_compile` change; splitting would have required a reverted intermediate state) — recorded above as one red→green line per finding.
## Deviations / notes (plan tail)
1. **Suite count 614, not 604**: the quoted baseline was 604 passed + 84 subtests; this branch measures 614 passed + 84 subtests on both the dev venv (py3.14) and the kill-test venv (py3.11) — baseline drift (extra tests landed), zero failures either way. Green is green.
2. **Pin-only comparison in the drift checker**: uv embeds the `-o <path>` (temp file) in its autogenerated header and the committed file carries a hand-maintained provenance header, so byte-identical comparison would false-fail every run. `check_dep_lock.py` compares normalized `name==version` pins only (comments/annotations/options stripped); unit-tested.
3. **CI drift step installs uv itself**: runners have no uv, so the lint drift-ratchet step does `pip install uv==0.10.11` into `.ci-venv` (pinned, tool-only — not a project dependency) before running the checker. The three venv install steps are exactly `-r requirements-ci.txt` + `-e . --no-deps` as planned.
4. **Pre-existing YAML breakage**: `.gitea/workflows/auto-version.yml` fails `yaml.safe_load` on base 2840f71 (before any of my changes; verified via `git stash`). Per instructions: noted, NOT fixed here (#192 fixes it separately). `maintenance.yml` parses clean on this base.
5. **Kill-test venv deletion**: `rm -r .venv-killtest` was blocked twice by the session's permission rules; deleted via `python3 -c shutil.rmtree` — verified gone.
## Research trace
Research round: issue #150 comment thread (posted 2026-09-07).
- ci.yml :20/:52/:68-72 floor-pin installs + canary `|| true` escape (INWARD) → all three venvs switch to pinned file; silent-drift escape deleted.
- uv 0.10.11 --help (verified locally: --extra, -o, --python-version flags) → uv pip compile against py3.11 markers = runner-reality lock, runners need no uv.
- Drift-ratchet precedent (pyright ratchet live in this repo) → regenerate-to-temp + diff + fail-CI, never auto-mutate.
Kill test: if `pip install -r requirements-ci.txt -e . --no-deps` + full suite (604+84) does NOT pass green, the lock is wrong — Task 2's pinned-env smoke is the kill test.
+194
View File
@@ -0,0 +1,194 @@
# P1-03 Unify memory selection and restore true abstention — Implementation Plan (#26)
> **Execution contract:** FROZEN TESTS ARE THE SPEC. Work tree: /mnt/workdrive/p1-03 (branch p1-03-memory-selection). TDD red → green per task; record every deviation in the plan tail. Do not create/complete tasks; controller owns task state. Conventional commits referencing (#26). Do not push to master directly; PR + protected checks only.
## Goal
One memory-selection API owned by `ContextBroker.memory_selector` (authority-map v1_owner), a real relevance threshold with **true abstention** (a trivial task legitimately receives ZERO recalled memories), memory-type targeting, no fallback injection merely because a memory is important, ContextBroker owning the final memory budget, and old-task retrieval that can never override the active TaskState.
## Architecture
- **New module `src/rune/memory_selector.py`** — `MemorySelector` class, the single memory-selection API. Composed by `ContextBroker` as `self.memory_selector` (authority map `config/v1_authority_map.json:120-150`, domain `memory_selection`, v1_owner `ContextBroker.memory_selector`).
- **Operator collapse:** the six parallel memory contributors in `rune_operator.py` (`_contributor_memory` :1165, `_contributor_persona_memories` :1201, `_contributor_consolidated` :1238, `_contributor_neurons` :1356, `_contributor_graphs` :1397, `_contributor_abstraction` :1435) are REPLACED by exactly ONE registered memory contributor that delegates to `broker.memory_selector.select_for_task(...)`. No parallel authority path — replace, not add.
- **Selector sources** (internal to the selector, each under the same threshold policy): typed store recall (`memory_store.recall`/`cached_recall`), warm-neuron scan (NO importance fallback), graph entity resolution, abstraction facts, consolidated knowledge (de-bypassed).
- **Fail-closed inversion:** selector or source failure → zero memory blocks (abstain), log, continue. Abstention is the safe default — never fail-open to "inject something important".
- **Trace v4** (`inference_trace.py`): additive optional fields `memory_candidates`, `memory_selected`, `memory_abstained`, `memory_types_queried` (carried additive discipline v3→v4).
## Tech stack
Python 3.14 stdlib + sqlite3; pytest; pyright ratchet (`scripts/type_check_ratchet.py` vs `pyright-baseline.json` 120e/465w — may only decrease); ruff (format --check on ADDED files, `--select E9,F63,F7,F82` on CHANGED); canary self-bootstrapping pattern; CI on rune-ci-desktop via `.gitea/workflows/ci.yml` canary for-loop (henxel `ci_runs_all_canaries` enforces registration).
## Global Constraints
- Preserve memory stores (handoff 6.15): selection changes HOW/WHEN memory is surfaced — no deletion of durable memory, no store rewrites.
- Nothing injects model context outside the ContextBroker (target-architecture invariant).
- `auto_context.py` is a TUI display artifact (not model context) — OUT OF SCOPE; note only.
- warm_router stays dormant-until-dependency per authority map; its ID-path defect (#27 P1-04 territory) is NOT fixed here.
- Suite baseline: **604 passed + 84 subtests** must stay green (known pre-existing env-only collection error in `test_build_pipeline_policy.py` without `-e .` install — not a regression).
- New code must be type-clean under the ratchet; house narrowing pattern (`if not ok or resolved is None: return` + assert); runtime guards widen params (`X | object`); dataclass field validation PRE-construction.
- No unrelated cognitive subsystems; no additive feature growth beyond this issue.
## Design Decisions (settled)
1. **Owner:** `ContextBroker.memory_selector` — `MemorySelector` lives in `src/rune/memory_selector.py`, composed by `ContextBroker` (mirrors `CapabilityBroker.select_for_job` shape: `select_for_task(task_state, budget) -> SelectionResult`).
2. **One API:** `SelectionResult(blocks: list[ContributionBlock], candidates_evaluated: int, selected_count: int, abstained: bool, types_queried: list[str])`. The operator registers exactly ONE memory contributor consuming it.
3. **Threshold policy:** single relevance floor — `memory_store.RECALL_MIN_SCORE` (0.42) default, applied to EVERY source. `gate_consolidated`'s flat-0.8 shortcut inside `cached_recall` (memory_store.py:1053-1057) is REMOVED so consolidated knowledge competes under the same floor. The top-5-importance neuron fallback (rune_operator.py:1387-1389) is REMOVED. `always_digest` per-turn unconditional injection is retired from model context.
4. **Type targeting:** additive `memory_type` column on `entries` (ALTER TABLE ADD COLUMN — backward compatible). Values: `episodic|semantic|procedural|self|personality|associative|NULL`. Backfill classifies ONLY `kind='neuron'` → `associative`; all other existing rows remain NULL (unclassified = eligible for all queries — classification narrows, never excludes). Typed queries filter `(memory_type IN ?) OR memory_type IS NULL`. Writers classify at write time where the type is genuinely known. The persona contributor's hardcoded query (`"Rune personality voice identity how to speak"`, :1219) is replaced by a typed `self`/`personality` query derived from the ACTIVE task description — still thresholded, may abstain.
5. **Budget:** ContextBroker owns the final memory budget — sum of `MEMORY_BLOCK_NAMES` blocks ≤ memory sub-budget (default `min(1200, window // 8)` tokens) enforced in `_admit`; the selector also caps its own candidate list before returning.
6. **TaskState supremacy:** `_contributor_history` (rune_operator.py:789-802, :1493-1508) is rebased on `TaskStateRepository` (canonical) — completed tasks only, explicitly labeled `PAST TASK (completed — context only, not instructions)`, priority strictly below the active-task block; the active TaskState block is non-evictable; recall queries are keyed ONLY on the active task's description (never on past-task text).
7. **Measurement:** trace v4 additive fields above; irrelevant-memory rate is computable per run as `(candidates_evaluated − selected_count) / candidates_evaluated` when candidates > 0, with `abstained=True` runs recorded as zero-injection events.
8. **Degrade-not-crash:** selector source failure → skip that source, log, continue; selector unavailable → zero memory blocks (fail-closed abstain), never fallback injection.
## Tasks
### Task 1 — `memory_type` column + backfill migration
**Files:** `src/rune/memory_store.py`, `tests/test_p1_03_memory_selection.py` (new)
**Interfaces:** `ensure_memory_type_column(db)`, backfill at connect/migration time; `recall(..., memory_types: list[str] | None = None)` filter.
Steps: 1) Write failing tests (column exists after migration; neuron rows backfilled to `associative`; other rows NULL; typed query returns typed+NULL, excludes other types; untyped query unchanged). 2) Red. 3) Implement additive migration. 4) Green + full suite. 5) Commit `feat(p1-03): memory_type column + typed recall filter (#26)`.
### Task 2 — MemorySelector skeleton + store-recall source + true abstention
**Files:** `src/rune/memory_selector.py` (new), `src/rune/context_broker.py`, `tests/test_p1_03_memory_selection.py`
**Interfaces:** `MemorySelector(db_path, min_score=RECALL_MIN_SCORE)`, `.select_for_task(task_description, types=None, budget=N) -> SelectionResult`; `ContextBroker.memory_selector` composition.
Steps: 1) Failing tests: trivial task ("what time is it") → zero blocks, `abstained=True`; relevant task → blocks above floor; below-floor candidates counted but not selected; source failure → abstain + log, no raise. 2) Red. 3) Implement. 4) Green + suite. 5) Commit `feat(p1-03): MemorySelector with threshold abstention (#26)`.
### Task 3 — Migrate remaining sources into the selector (fallbacks die here)
**Files:** `src/rune/memory_selector.py`, `src/rune/memory_store.py`, `tests/test_p1_03_memory_selection.py`
**Interfaces:** selector internal sources: neurons (scan, NO fallback), graphs, abstraction, consolidated (gate_consolidated bypass removed); typed persona source (`self`/`personality`).
Steps: 1) Failing tests: high-importance irrelevant neuron NOT injected (fallback gone); consolidated item below floor abstains (flat-0.8 gone); persona typed query replaces hardcoded query and abstains on trivial task. 2) Red. 3) Implement sources + remove `gate_consolidated` shortcut. 4) Green + suite. 5) Commit `feat(p1-03): selector sources unified, bypasses removed (#26)`.
### Task 4 — Operator single-contributor collapse + TaskState supremacy
**Files:** `src/rune/rune_operator.py`, `tests/test_p1_03_memory_selection.py`
**Interfaces:** one `memory` contributor delegating to `broker.memory_selector`; `_contributor_history` rebased on `TaskStateRepository` with past-task labeling; TaskState block non-evictable.
Steps: 1) Failing tests: exactly one memory contributor registered; prompt for a trivial task contains NO memory blocks (Context/Identity/knowledge/neurons/graphs/abstraction all absent); old completed-task content cannot outrank active TaskState (priority + labeling assertions); recall query never keyed on past-task text. 2) Red. 3) Collapse contributors, rebase history, enforce priorities. 4) Green + suite. 5) Commit `feat(p1-03): single memory contributor, TaskState supremacy (#26)`.
### Task 5 — Memory sub-budget + trace v4 + canary + CI + docs
**Files:** `src/rune/context_broker.py`, `src/rune/inference_trace.py`, `src/rune/canary_p1_03.py` (new), `.gitea/workflows/ci.yml`, `docs/` (memory selection section), `tests/test_p1_03_memory_selection.py`
**Interfaces:** `memory_budget` on ContextBroker (default `min(1200, window // 8)`); trace v4 additive fields; canary self-bootstrapping (`RUNE_HOME`/`RUNE_MEMORY_DIR` temp isolation, prints `P1-03 ... CANARY PASSED`, exit 0); CI for-loop registration (henxel enforces).
Steps: 1) Failing tests: memory blocks evicted beyond sub-budget; trace carries v4 fields on assemble; canary assertions (trivial→zero, fallback-dead, TaskState supremacy). 2) Red. 3) Implement budget + trace + canary + CI list entry + docs. 4) Green + suite + canary + ratchet + ruff + henxels. 5) Commit `feat(p1-03): memory budget, trace v4, canary (#26)`.
## Acceptance (from #26)
- Trivial task can legitimately receive zero recalled memories → Task 2 tests + Task 4 prompt assertion + canary.
- Irrelevant-memory rate is measured → Task 5 trace v4 fields (`memory_candidates`/`memory_selected`/`memory_abstained`).
- Old-task retrieval cannot override TaskState → Task 4 tests + canary.
Actions covered: one memory-selection API (Tasks 2-4), relevance threshold (Tasks 2-3), memory-type targeting (Tasks 1, 3), no fallback injection (Task 3), ContextBroker owns final memory budget (Task 5).
## Pipeline requirements checklist (from #26)
- [ ] TDD: frozen/regression tests written first, red → green
- [ ] `ruff check` and `ruff format --check` clean
- [ ] Spec-compliance review gate: PASS
- [ ] Code-quality review gate: Approved
- [ ] Full orchestration suite regression green
- [ ] Canary passes (canary_p1_03 + all registered canaries)
- [ ] Closed with a properly formatted closing comment + commit/PR references
## Completion Record
(status: **COMPLETE** — implementer record, 2026-09-07)
### Commits (branch p1-03-memory-selection, in order)
| Task | Commit | Message |
| --- | --- | --- |
| 1 | `fde27d7` | feat(p1-03): memory_type column + typed recall filter (#26) |
| 2 | `f998768` | feat(p1-03): MemorySelector with threshold abstention (#26) |
| 3 | `ff04888` | feat(p1-03): selector sources unified, bypasses removed (#26) |
| 4 | `32de159` | feat(p1-03): single memory contributor, TaskState supremacy (#26) |
| 5 | `7101087` | feat(p1-03): memory budget, trace v5, canary (#26) |
| fix | `57c0faf` | fix(p1-03): prior canary trace-version pins follow the schema constant (#26) |
### Suite counts
- Baseline (master 2840f71): **604 passed + 84 subtests**; ratchet 117e/407w.
- Final: **655 passed + 84 subtests** (+51 frozen tests, all red-first); ratchet **116e/418w** (baseline 120/465 — improved, never increased).
- henxels_checks.py: **exit 0** (incl. `ci_runs_all_canaries`).
- ruff format + `--select E9,F63,F7,F82`: clean on every added/changed file.
- Canary battery (all 24 registered canaries, system python3): **23/24 pass**; `canary_native.py` has 2 pre-existing import-check failures (`pins.list_pins`, `event_bus.stats` — "No module named 'rune'"), verified **identical on master 2840f71** via a temp worktree — not a regression, untouched (out of scope). `canary_p1_03`, `canary_p1_02`, `canary_p1_01`, `canary_p1_06` all green.
### Red→green evidence per task
- **Task 1**: 8/8 frozen tests failed (no `memory_type` column, no `ensure_memory_type_column`, no `memory_types` param) → green 8/8; suite 612.
- **Task 2**: 14 failed (no `memory_selector` module, no broker composition) → green 23/23; suite 627.
- **Task 3**: 5 failed (importance fallback alive, flat-0.8 bypass present, no persona source, sources unregistered) → green 33 + 2 Task-4-deferred red; suite 637.
- **Task 4**: 6 failed (six memory contributors registered, unlabeled history, legacy-ledger history, recall seam) → green 43/43; suite 647.
- **Task 5**: 8 failed (no sub-budget, no v5 fields, no doc/canary/CI entry) → green 51/51; suite 655.
### Deviations (with rationale)
1. **Trace schema v5, not v4.** The plan assumed inference_trace at v3; P1-02 (#25) had already landed v4 (capsule fidelity). P1-03's additive fields therefore land as **v5** under the same additive discipline. Prior pinned assertions (test_p1_01, test_p1_02, canary_p1_01, canary_p1_06) were updated to follow the module constant — exactly what P1-02 did to P1-01's pin (commit 21c4023).
2. **`memory_store.connect(db_path=None)`** gained an optional explicit-path param (Task 2 commit): `MemorySelector(db_path)` must pin the store it selects from. Additive, backward compatible; plan's Task 2 file list omitted memory_store.py.
3. **Shard routing bypass.** Typed recall (`memory_types`) and the selector's store source bypass `ShardManager` routing: shards live at a hardcoded `~/.config/opencode/memory/shards` (ignores RUNE_* isolation) and pre-migration shard DBs carry **no `memory_type` column**, so typed recall through a shard is impossible. Untyped direct `recall()` callers keep existing routing behavior.
4. **Consolidated source: per-item scoring; write side-effect retired.** The flat-0.8 whole-blob injection is replaced by per-knowledge-item lexical scoring under the floor. The collapsed `_contributor_consolidated`'s per-turn `_save_knowledge()` write is retired — consolidated knowledge is now read-only through the selector; no durable data deleted (preserve-stores constraint holds). Plan was silent on the write; reviewer flag below.
5. **Graphs/abstraction sources fixed to real schemas.** The old contributors were silent always-degrade paths: `graph_memory.resolve_entity()` mutates the graph (creates missing entities) and returns an int id (old `e.get('name')` could never work); `abstraction.facts` uses column `state`, not `status` (old query raised). New sources use read-only `graph_memory.query()` and `state='active'`.
6. **Test-seam migration (3 pre-existing characterization tests).** test_context_broker/test_rune_operator_tools patched `memory_store.cached_recall` — a seam the collapsed operator no longer calls. Assertions kept at equal strength; the memory seam moved to the selector's store source (patch.dict on `DEFAULT_SOURCES`). The second-turn characterization now expects the labeled past-task format — labeling IS an issue acceptance criterion, not a weakened assertion.
7. **Two Task-4 tests authored during Task 3** (operator-source assertions: hardcoded persona query gone, importance fallback gone) stayed red through the Task 3 commit and went green in Task 4 — they assert the operator source, which Task 3's file list deliberately does not touch.
### Reviewer flags
- **Consolidation write retirement** (deviation 4): confirm intended — the plan's "replace, not add" + preserve-stores reading says yes, but it is a behavior change (knowledge.json stops growing per-turn).
- **Store-source floor exemption**: store candidates skip the selector-level numeric comparison because the SAME constant is applied inside `memory_store.recall`'s calibrated gate (rows tagged `store_scored`); documented in memory_selector.py. Sanity-check the policy (renormalizing the store's mixed lexical/dense relevance scales would have produced false abstentions).
- **Isolation gap (pre-existing, noted for a future issue)**: `memory_consolidation` hardcodes `~/.rune/consolidation` and `shard_manager` hardcodes `~/.config/opencode` — neither honors RUNE_* env isolation. Tests/canary redirect HOME; production code untouched.
- Acceptance criteria all machine-checked: trivial→zero memories (Task 2/4 tests + canary check 1); irrelevant-memory rate measured (trace v5 fields + SelectionResult.irrelevant_rate); old-task cannot override TaskState (Task 4 tests + canary check 3).
## Discharge Record (post-gate findings, 2026-09-07)
Both review gates returned 9 findings (2 MAJOR, 7 MINOR). All discharged red-first on
branch p1-03-memory-selection; every finding below maps to its commit and its red→green
evidence. Suite after discharge: **670 passed + 84 subtests** (baseline 655+84; +15
discharge tests, all red-first); pyright ratchet **held at 116e/418w**; ruff
format/check clean on touched files that were formatted at HEAD; henxels exit 0;
canary battery **24/24** (incl. canary_p1_03).
| # | Finding | Commit | Red→green |
|---|---|---|---|
| 1 | Store floor only enforced in dense-only recall; lexical-only + hybrid paths never applied `min_score`, and the selector exempted `store_scored` rows from its own floor — a below-floor store row through lexical/hybrid would inject | `3f53d93` `fix(p1-03): recall applies the ONE floor in every mode` | RED: `test_recall_lexical_only_applies_min_score_floor` (3 seeded rows, floor 5.5 → rows returned) + `test_recall_hybrid_below_floor_row_abstains` (cos≈0.37 row injected through hybrid). GREEN: both abstain; hybrid above-floor positive control still surfaces. Floor choice: enforced INSIDE `memory_store.recall` in every mode (lexical best-relevance check additive to the conjunctive FTS gate; hybrid gates on the calibrated dense cosine, same as dense-only); selector `:204` claim rewritten to match. |
| 2 | Typed persona source dead in production (operator calls `select_for_task` with NO types so `_persona_recall` always returned `[]`); no production writer set `memory_type` | `70c2007` `feat(p1-03): typed persona lane + write-time memory_type classification` | RED: `test_contributor_memory_runs_typed_persona_query` (no `memory_types=['self','personality']` call in the live contributor path) + `test_add_entry_memory_type_round_trip` (TypeError). GREEN: the store source runs a SECOND typed query (`self`/`personality`) merged into candidates (thresholded, deduped by id, may abstain — trivial task still gets zero); `add_entry` + store CLI `--memory-type` classify at write time; wired where the writer genuinely knows the type: warm-router neuron writer → `associative`, sleep-time growth sync → `personality` (persona-targeted) / `semantic`. |
| 3 | `_persona_recall` ignored the selector's pinned `db_path` (opened the module-default store) | `fix(p1-03): selector sources honor the pinned db_path` | RED: `test_persona_source_uses_pinned_db_path` + `test_store_source_uses_pinned_db_path` (pinned store seeded, module-default store empty → no block). GREEN: pinned `db_path` is bound into sources that declare it at registration; both sources read the pin. |
| 4 | `ensure_memory_type_column` ran an unindexed `UPDATE ... WHERE kind='neuron'` scan on EVERY connect; the selector opens a fresh connection per turn | `ea5de21` `fix(p1-03): neuron backfill runs at migration time only` | RED: `test_backfill_runs_once_at_migration_not_every_connect` (NULL marker row flipped back to `associative` by the second connect). GREEN: connect() only invokes the migration when the column is actually missing; explicit `ensure_memory_type_column()` keeps the frozen self-healing semantics (re-classifies unclassified neurons, never touches explicit classifications). |
| 5 | Vacuous operator-level assertions (`block is None or ...` pass trivially on empty tmp stores) | `test(p1-03): strengthen vacuous selector assertions` | Not red-first (test-only): the four tests now seed the store and assert the positive branch — real block, exact source, history priority/label from a seeded completed task. |
| 6 | Weak assertions: `cached_recall` test escaped via `or source=="consolidated"`; fallback-gone test grepped one literal | same commit as #5 | `test_cached_recall_never_returns_flat_eight_tenths` now asserts the calibrated recall shape (rrf/confidence/id present, relevance ≠ 0.8) with shard routing disabled (it silently rerouted to an empty shard, vacating the old assertion) + abstention twin + positive twin; `test_operator_importance_fallback_gone` greps ALL fallback slice patterns + a behavioral twin (off-trigger high-importance neuron does not inject through the operator). |
| 7 | `gate_consolidated` dead code | deletion rode into `fix(p1-03): recall applies the ONE floor in every mode` (memory_store staged as a whole file); retirement proof in `refactor(p1-03): canary proves gate_consolidated retirement via AST` | Function deleted; `canary_query_memory`'s gate check now proves the retirement via AST parse of the REPO copy (the old check inspected whichever `memory_store` a sys.path shim resolved first). Suite stays green. |
| 8 | Canary trivial-check ran on an empty store (capsule-level zero-memory proof not seeded with a distractor) | `b8ada5f` `test(p1-03): seed canary trivial-check distractor` | Not red-first (canary-only): `check_trivial_task_abstains` now seeds a high-importance fact distractor + a personality row before assembling; zero-memory proof is capsule-level meaningful. Canary passes. |
| 9 | Ranking scale mix (store lexical ~5.x vs dense/hybrid RRF ~0-0.13 vs self-scored 0-1 ranked raw together) | documented in `3f53d93` (selector docstring) + this record | Per the finding's escape hatch: KNOWN RANKING BIAS documented in `MemorySelector` class docstring + `_store_recall` docstring + docs/memory-selection.md. Abstention unaffected (each source floored on its own calibrated scale); per-source rank normalization deliberately deferred (renormalizing the store's calibrated scales risked false abstentions). |
### Discharge deviations (with rationale)
1. **Floor enforced in the STORE (all modes), not in the selector.** The finding offered
either place. The store is where the calibrated gate lives; every direct `recall()`
caller gets the floor for free, and the selector's `store_scored` exemption becomes
literally true. The lexical branch's numeric check is additive to the conjunctive FTS
gate (which stays as-is); the hybrid branch gates on the dense cosine — the scale
`min_score` was actually calibrated against (RRF-fused relevance is rank-based and is
NOT compared against the floor).
2. **Persona lane lives in the store source, not `_persona_recall`.** The finding's FIX (a)
prescribed exactly this ("the selector's store source runs a SECOND typed query");
`_persona_recall` remains the typed-caller source and now also honors the pinned
db_path. Lane results are deduplicated by entry id (the untyped and typed queries
overlap on unclassified rows — duplicates would corrupt the irrelevant-memory rate and
repeat block text).
3. **Persona-memory write path**: sleep_time's growth sync IS a persona write path
(evidence items target `persona`), so it now stamps `memory_type=personality`
(`semantic` for other growth targets) via the store CLI's new `--memory-type` flag —
including through the dedupe gate (the surviving row is classified after
`dedupe_policy.apply`). No writer was invented beyond what genuinely knows its type.
4. **Backfill self-healing preserved on explicit calls.** The finding said "backfill only
when the column was JUST added (migration-time)"; connect() now honors that strictly,
but an EXPLICIT `ensure_memory_type_column()` call keeps the frozen self-healing
semantics (re-classifies unclassified neuron rows) — the per-CONNECT scan is what was
eliminated. The frozen `test_backfill_classifies_only_neuron_rows` still passes
unchanged.
5. **`canary_query_memory` gate check rewritten as a retirement proof** (AST parse of the
repo copy): the old check inspected `memory_store.gate_consolidated` through a sys.path
shim that resolves the DEPLOYED scripts copy first — deterministic in neither
direction, and obsolete once the function was deleted.
6. **`test_cached_recall_*` tests disable shard routing** (`sys.modules["shard_manager"]
= None`): shard routing can silently reroute to an out-of-repo empty shard, which is
what made the original flat-0.8 assertion vacuous. Queries are unique per test to
defeat the in-process recall cache.
7. **ruff format scope**: `warm_router.py`, `sleep_time.py`, `canary_query_memory.py`
were already unformatted at HEAD (pre-existing); wholesale reformatting would bury the
2-8 line functional changes in noise, so those files keep their local style and only
`ruff check --select E9,F63,F7,F82` is asserted (clean). Files formatted at HEAD
(memory_store, memory_selector, canary_p1_03, test file) remain ruff-format clean.
-47
View File
@@ -1,47 +0,0 @@
# Research round prompt template — RESEARCH ROUND V3 (4-phase)
#
# GOVERNED ARTIFACT: this file IS the research prompt consumed by the headless
# research stage (.gitea/workflows/research-stage-headless.yml). Placeholders
# {{ISSUE_NUMBER}}, {{ISSUE_TITLE}}, {{ISSUE_BODY}} are substituted at run time.
#
# SEAM (Nick, 2026-09-07): the prompt currently lives in-repo; the planned
# evolution is the Rune TUI calling back to own/generate this prompt per issue.
# Keeping it as a versioned file makes that a drop-in: the TUI writes this file
# (or a per-issue override) via PR; the stage always executes the current
# template. Prompt changes are reviewed diffs, never silent.
You are the RESEARCH ROUND agent for issue #{{ISSUE_NUMBER}}.
ISSUE TITLE: {{ISSUE_TITLE}}
ISSUE BODY:
{{ISSUE_BODY}}
Perform RESEARCH ROUND V3 in exactly four phases:
1. **INWARD** — codebase analysis. Map the relevant modules, call sites, tests,
and contracts this issue touches. Cite repo paths with file:line evidence.
2. **OUTWARD** — state-of-the-art survey. You MUST fetch live sources (curl,
arxiv, project docs) and cite real URLs you actually retrieved this run.
If a link is dead, substitute it and say so. Ask the driving question:
"is there anything BETTER than the obvious approach?"
3. **INNOVATION** — viability-filtered improvements. From the survey, list
candidate improvements, evaluate each against effort/risk/fit, and mark
which (if any) to adopt. Rejecting all candidates with reasons is a valid
outcome — honesty over novelty.
4. **SYNTHESIS** — the deliverable. End with concrete, ordered BUILD
INSTRUCTIONS: files to touch, tests to write (red-first), gates that apply
(umbrella propagation, canaries), and the PR strategy. These instructions
become the implementer's prompt — write them so an agent could execute
them without re-doing the research.
Phase headers must be exactly: INWARD, OUTWARD, INNOVATION, SYNTHESIS (each
on its own line) — the research-clear gate keys on them.
OUTPUT CONTRACT (exactly two artifacts):
1. Write the full round to research/issue-{{ISSUE_NUMBER}}-research.md
(create the directory if needed). Begin the file with:
"Research round for #{{ISSUE_NUMBER}} — {{ISSUE_TITLE}} (4-phase,
agent-generated)".
2. Print the SAME content as your final message (the workflow files it).
Do NOT commit or push anything. The workflow handles filing.
-120
View File
@@ -1,120 +0,0 @@
# CI dependency lock (issue #150) — fully pinned, generated from pyproject.toml.
#
# Regenerate after ANY dependency edit (then commit the result):
# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
# Generated: 2026-09-07 (uv 0.10.11)
# Rationale: floor pins like textual>=8.2.0 plus floating transitive deps resolve differently by
# date; same commit + same lock = same CI environment. Plain pip format — runners need no uv.
# Drift ratchet: scripts/check_dep_lock.py fails CI if this file no longer matches pyproject.toml.
# NOTE: this header is hand-maintained; scripts/check_dep_lock.py compares requirement pins only
# (comments/annotations stripped), so the header and uv's per-line 'via' annotations may drift.
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
aiohappyeyeballs==2.7.1
# via aiohttp
aiohttp==3.14.3
# via rune (pyproject.toml)
aiosignal==1.4.0
# via aiohttp
anyio==4.15.1
# via httpx
attrs==26.1.0
# via
# aiohttp
# pytest-subtests
certifi==2026.7.22
# via
# httpcore
# httpx
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
h11==0.16.0
# via httpcore
henxels==0.12.0
# via rune (pyproject.toml)
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via rune (pyproject.toml)
idna==3.19
# via
# anyio
# httpx
# yarl
iniconfig==2.3.0
# via pytest
linkify-it-py==2.2.0
# via markdown-it-py
markdown-it-py==4.2.0
# via
# mdit-py-plugins
# rich
# textual
mdit-py-plugins==0.6.1
# via textual
mdurl==0.1.2
# via markdown-it-py
multidict==6.7.1
# via
# aiohttp
# yarl
nodeenv==1.10.0
# via pyright
packaging==26.3
# via pytest
platformdirs==4.11.7
# via textual
pluggy==1.6.0
# via pytest
propcache==0.5.2
# via
# aiohttp
# yarl
psutil==7.2.2
# via rune (pyproject.toml)
pygments==2.21.0
# via
# pytest
# rich
# textual
pyright==1.1.411
# via rune (pyproject.toml)
pytest==9.1.1
# via
# rune (pyproject.toml)
# pytest-subtests
pytest-subtests==0.15.0
# via rune (pyproject.toml)
pyyaml==6.0.3
# via
# rune (pyproject.toml)
# henxels
rich==15.0.0
# via
# rune (pyproject.toml)
# textual
ruff==0.16.5
# via rune (pyproject.toml)
sqlite-vec==0.1.9
# via rune (pyproject.toml)
textual==8.2.8
# via
# rune (pyproject.toml)
# textual-autocomplete
textual-autocomplete==4.0.6
# via rune (pyproject.toml)
typing-extensions==4.16.0
# via
# aiohttp
# aiosignal
# anyio
# pyright
# textual
# textual-autocomplete
websockets==17.1
# via rune (pyproject.toml)
yarl==1.24.5
# via aiohttp
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env python3
"""CI dependency-lock drift ratchet (#150).
`requirements-ci.txt` is the committed, fully-pinned CI dependency set,
generated from pyproject.toml. This script re-runs the compile into a temp
file and fails if the committed lock no longer matches pyproject.toml —
the lock is a ratchet, reviewed and re-committed, never auto-mutated.
Regenerate after ANY dependency edit (then commit the result):
uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
--python-version 3.11 matches the CI runners (not the dev machine's
interpreter) so environment markers resolve to runner reality. CI installs
this file with plain pip and then `pip install -e . --no-deps`: the project's
own dependencies come ONLY from the pinned file, so `requirements-ci.txt` is
plain requirements.txt format and runners need no uv.
Comparison is on requirement pins only (comments and uv's per-line "via"
annotations are stripped) because the hand-maintained header — and uv's
temp-file `-o` header path — cannot regenerate byte-identically.
Known limitations, disclosed (#150 gate review — cheap, honest, accepted):
1. Marker-only drift is invisible. Environment markers (`; python_version
< "3.12"`) are stripped before comparison, so a lock change that alters
ONLY a marker leaves the ratchet green. Regenerate + eyeball the diff
after any marker-affecting edit; the pin diff cannot catch it.
2. Direct-URL requirement lines (`name @ https://...`) do not fit the
`==`-pin contract: they trip the loud non-pinned-line failure above
rather than being compared. This repo's lock is index-generated and has
none; if a direct-URL dependency is ever locked, the checker must be
taught the form first (it cannot silently pass one — that much holds).
"""
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCK_FILE = ROOT / "requirements-ci.txt"
COMPILE_ARGS = [
"pip",
"compile",
"pyproject.toml",
"-o",
"{out}",
"--extra",
"dev",
"--extra",
"memory",
"--python-version",
"3.11",
]
REGEN_HINT = "run: uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11 && commit"
def uv_binary() -> str:
"""Locate the uv binary to compile with, most-trusted first.
1. Interpreter-adjacent (sys.executable's directory): CI installs the
PINNED uv into the same venv that runs this script, so that copy is
the one whose resolver generated the lock. A uv found on PATH could
be any version and silently compile with a different resolver.
2. shutil.which (PATH).
3. ~/.local/bin/uv (the documented local install location).
"""
adjacent = Path(sys.executable).resolve().parent / "uv"
if adjacent.is_file():
return str(adjacent)
found = shutil.which("uv")
if found:
return found
fallback = Path.home() / ".local" / "bin" / "uv"
if fallback.is_file():
return str(fallback)
return "uv"
def run_uv_compile(out_path: Path, root: Path = ROOT) -> int:
"""Compile pyproject into out_path; return the subprocess exit code.
uv's full resolution listing (stdout) is captured and only printed when
the compile fails — on success it is noise on CI logs (#150 gate review).
"""
args = [uv_binary()] + [part.format(out=str(out_path)) for part in COMPILE_ARGS]
try:
proc = subprocess.run(args, cwd=str(root), check=False, capture_output=True, text=True)
except FileNotFoundError:
return -1
if proc.returncode != 0:
if proc.stdout:
print(proc.stdout, file=sys.stderr)
if proc.stderr:
print(proc.stderr, file=sys.stderr)
return proc.returncode
def lock_requirements(path: Path) -> dict[str, str]:
"""Extract normalized name -> pinned version from a requirements file.
Strips comments (whole-line and uv's indented 'via' annotations) and
skips options (-r/--index-url...). Options are the ONLY tolerated
non-pin lines: any requirement line that is not exactly pinned with
'==' (floor specs like 'rune>=0.49.0', ranges, wildcards, bare names)
exits 1 with an actionable message — a degraded floor-style lock must
fail loudly, never silently pass the pin diff (#150 gate review).
"""
pins: dict[str, str] = {}
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
# uv puts 'via' annotations on their own indented '# ...' lines; be
# defensive about inline comments too.
line = re.sub(r"\s+#.*$", "", line).strip()
if line.startswith("-"):
continue # options (-r, -c, --index-url, ...)
line = line.split(";", 1)[0].strip() # drop environment markers
version = line.partition("==")[2].strip()
if "==" not in line or not version or "*" in version:
print(
f"FAIL: {path} contains a requirement that is not exactly pinned: '{line}'\n"
"Every lock line must pin with '==' (no floors like '>=', ranges,"
" wildcards, or bare names) — otherwise the same commit resolves\n"
"differently by date, which is exactly what this lock exists to"
" prevent.\n"
f"Fix the line or regenerate the lock: {REGEN_HINT}",
file=sys.stderr,
)
raise SystemExit(1)
name, _, _ = line.partition("==")
name = re.sub(r"[-_.]+", "-", name.strip()).lower()
pins[name] = version
return pins
def compare(committed: Path, regenerated: Path) -> str | None:
"""Return None when pins match, else a human-readable drift report."""
old = lock_requirements(committed)
new = lock_requirements(regenerated)
problems = []
for name in sorted(set(old) - set(new)):
problems.append(f" {name}=={old[name]} pinned in lock but no longer resolved")
for name in sorted(set(new) - set(old)):
problems.append(f" {name}=={new[name]} resolved but missing from lock")
for name in sorted(set(old) & set(new)):
if old[name] != new[name]:
problems.append(f" {name}: {old[name]} (lock) -> {new[name]} (regenerated)")
if not problems:
return None
return "requirements-ci.txt is out of sync with pyproject.toml:\n" + "\n".join(problems) + f"\n{REGEN_HINT}"
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
regenerated = Path(tmp) / "requirements-ci.regenerated.txt"
code = run_uv_compile(regenerated)
if code == -1:
# FileNotFoundError from subprocess: the uv binary itself is
# missing. House actionable pattern — what to install, what to
# run — instead of a raw traceback (#150 gate review).
print(
"FAIL: 'uv' executable not found (checked interpreter-adjacent dir,"
" PATH, and ~/.local/bin/uv).\n"
"Install the pinned uv into the CI venv and put it on PATH:\n"
" .ci-venv/bin/pip install --disable-pip-version-check uv==0.10.11\n"
' export PATH="$PWD/.ci-venv/bin:$PATH"\n'
"Locally: see https://docs.astral.sh/uv/getting-started/installation/",
file=sys.stderr,
)
return 1
if code != 0:
print(
f"FAIL: dependency-lock regeneration failed (uv pip compile exited non-zero). {REGEN_HINT}",
file=sys.stderr,
)
return 1
report = compare(LOCK_FILE, regenerated)
if report is None:
print("Dependency lock in sync with pyproject.toml (requirements-ci.txt)")
return 0
print(f"FAIL: {report}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+2 -12
View File
@@ -48,24 +48,14 @@ def _api(
body = e.read()
except urllib.error.URLError as e:
raise MergeRefused(f"forge unreachable at {path}: {e.reason}") from e
try:
parsed: Any = json.loads(body) if body else {}
except json.JSONDecodeError:
if status not in ok:
raise MergeRefused(f"{method} {path} -> {status} (non-JSON body): {body[:200]}") from None
parsed = body # 2xx but not JSON — pass through raw
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:
# Defense in depth: whatever scope the caller hands us, identity is an
# INSTANCE-scoped endpoint (#214 follow-up: repo-scoped base produced
# .../repos/o/r/user -> 404 -> unhandled JSONDecodeError).
stripped = base.rstrip("/")
instance = stripped.split("/repos/")[0] if "/repos/" in stripped else base
status, user = _api(instance, token, "/user")
status, user = _api(base, token, "/user")
login = user.get("login", "?")
print(f"merge-robot: authenticated as {login} (status {status})")
return login
-169
View File
@@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""research_backfill_sweep.py — one-time research-pending backfill (#204).
The research gate (pipeline-gate.yml) applies `research-pending` only on
ready-label EVENTS. Standing backlog issues predate the gate → never labeled
→ never researched. This sweep applies the gate's OWN readiness semantics to
the whole backlog:
eligible = open issue, not blocked, not already research-pending,
no existing 4-phase research round (INWARD/OUTWARD/INNOVATION/
SYNTHESIS comment — research already done stays done),
all `Depends on: #N` closed (verified against the server;
a missing dep is UNSATISFIED, never assumed closed).
Nick's critique, structuralized: "are these issues with research already
done?" — the sweep detects existing research and skips it. Research is never
re-dispatched for completed work.
Dry-run by default. `--apply` is the explicit mutation. Idempotent: safe to
re-run; labeled/satisfied issues are skipped on the next pass.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from typing import Any
PHASES = ("INWARD", "OUTWARD", "INNOVATION", "SYNTHESIS")
DEP_RE = re.compile(r"Depends on:\s*#(\d+)", re.IGNORECASE)
class SweepError(Exception):
"""A sweep precondition or server call failed — fail loud, never guess."""
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, status = r.read(), r.status
except urllib.error.HTTPError as e:
status, body = e.code, e.read()
except urllib.error.URLError as e:
raise SweepError(f"forge unreachable at {path}: {e.reason}") from e
try:
parsed: Any = json.loads(body) if body else {}
except json.JSONDecodeError:
if status not in ok:
raise SweepError(f"{method} {path} -> {status} (non-JSON): {body[:200]}") from None
parsed = body
if method == "GET" and status not in ok:
raise SweepError(f"GET {path} -> {status}: {str(parsed)[:300]}")
return status, parsed
class ForgeAdapter:
"""Thin .get()/.post() facade over _api so logic is testable."""
def __init__(self, base: str, token: str):
self.base, self.token = base, token
def get(self, path: str) -> tuple[int, Any]:
return _api(self.base, self.token, path)
def post(self, path: str, data: Any) -> tuple[int, Any]:
return _api(self.base, self.token, path, "POST", data)
def has_research_round(comments: list[dict]) -> bool:
"""True if any comment carries all four phase markers (gate's own test)."""
for c in comments:
body = c.get("body", "")
if all(re.search(rf"\b{p}\b", body, re.IGNORECASE) for p in PHASES):
return True
return False
def compute_targets(issues: list[dict], api) -> list[dict]:
"""Eligible issues for research-pending labeling.
`api` is a ForgeAdapter-like object exposing .get(path) -> (status, body).
Gate semantics: open, not blocked, not already labeled, no existing
research round, all deps server-verified closed (missing = unsatisfied).
"""
targets = []
for i in issues:
if i.get("pull_request") or i.get("state") != "open":
continue
labels = {l["name"] for l in i.get("labels", [])}
if "blocked" in labels or "research-pending" in labels:
continue
deps = [int(m) for m in DEP_RE.findall(i.get("body") or "")]
unsatisfied = False
for d in deps:
status, dep = api.get(f"/issues/{d}")
if status != 200 or dep.get("state") != "closed":
unsatisfied = True
break
if unsatisfied:
continue
_, comments = api.get(f"/issues/{i['number']}/comments")
if has_research_round(comments if isinstance(comments, list) else []):
continue
targets.append(i)
return targets
def apply(api, numbers: list[int]) -> list[int]:
"""POST research-pending for each number; returns applied list."""
for n in numbers:
api.post(f"/issues/{n}/labels", {"labels": ["research-pending"]})
return numbers
def fetch_open_issues(api) -> list[dict]:
"""Paginated open issues (PRs filtered downstream) — page cap never
silently truncates the candidate set (teach-job lesson)."""
issues: list[dict] = []
page = 1
while True:
status, batch = api.get(f"/issues?state=open&type=issues&limit=50&page={page}")
if not isinstance(batch, list):
raise SweepError(f"issues page {page} returned non-list: {str(batch)[:200]}")
issues.extend(batch)
if len(batch) < 50:
return issues
page += 1
def main_with(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Research-pending backlog sweep")
parser.add_argument("cmd", choices=["sweep"])
parser.add_argument("--base", required=True, help="repo-scoped API base …/repos/<o>/<r>")
parser.add_argument("--token", required=True)
parser.add_argument("--apply", action="store_true", help="label for real (default dry-run)")
args = parser.parse_args(argv[1:])
api = ForgeAdapter(args.base, args.token)
issues = fetch_open_issues(api)
targets = compute_targets(issues, api)
numbers = sorted(t["number"] for t in targets)
mode = "APPLY" if args.apply else "DRY-RUN"
print(f"research-sweep[{mode}]: {len(issues)} open issues, {len(numbers)} eligible")
for t in targets:
print(f" #{t['number']}: {t['title'][:70]}")
if args.apply and numbers:
apply(api, numbers)
print(f"research-sweep: labeled {len(numbers)} issue(s) research-pending")
return 0
def main() -> int:
return main_with(sys.argv)
if __name__ == "__main__":
sys.exit(main())
+4 -2
View File
@@ -247,8 +247,10 @@ def check_scripted_operator_wiring() -> int:
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 4:
return _fail(f"trace schema_version must be 4, got {record['schema_version']}")
if record["schema_version"] != it.INFERENCE_TRACE_SCHEMA_VERSION:
return _fail(
f"trace schema_version must be {it.INFERENCE_TRACE_SCHEMA_VERSION}, got {record['schema_version']}"
)
if record["selected_tools"] != expected:
return _fail(f"trace selected_tools wrong: {record['selected_tools']}")
if not record["capability_reason"]:
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""P1-03 memory-selection canary (#26).
Self-bootstrapping like canary_p1_02.py (CI runs ``python3 <canary>``
without PYTHONPATH). Proves the #26 claims are machine-checked with NO
live model, NO Ollama, NO network:
1. trivial task -> ZERO memory blocks (true abstention — the acceptance
criterion; the trace proves memory_tokens == 0);
2. a high-importance irrelevant neuron is NOT injected (the
top-5-importance fallback is dead) while a trigger-matched neuron still
surfaces through the selector;
3. old completed-task content can never override the active TaskState:
past-task text is labeled context-only, the history block's priority
stays strictly below the active task block, and the recall query is
keyed ONLY on the active task description;
4. ContextBroker owns the final memory budget: a memory block above the
sub-budget is evicted with a ``memory budget`` reason.
Isolates ALL state under a temp HOME (so ~/.rune and ~/.config/opencode
shards are redirected too) plus RUNE_HOME/RUNE_MEMORY_DIR, so the canary
never touches a real memory store. Prints 'P1-03 MEMORY SELECTION CANARY
PASSED' and exits 0.
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
# Self-bootstrap: CI runs `python3 <canary>` without PYTHONPATH, so put the
# repository src/ on sys.path before importing rune modules. Redirect HOME
# FIRST so every expanduser path (memory_env defaults, ~/.rune/consolidation,
# ~/.config/opencode shards) lands inside the temp sandbox.
_repo_src = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, _repo_src)
_canary_home = tempfile.mkdtemp(prefix="p1-03-canary-home-")
os.environ["HOME"] = _canary_home
os.environ["RUNE_HOME"] = str(Path(_canary_home) / "rune-home")
os.environ["RUNE_MEMORY_DIR"] = str(Path(_canary_home) / "memory")
from rune import memory_consolidation as _mc # noqa: E402
_consolidation_dir = Path(_canary_home) / "consolidation"
_mc.CONSOLIDATION_DIR = str(_consolidation_dir)
_mc.KNOWLEDGE_FILE = str(_consolidation_dir / "knowledge.json")
_mc.LESSONS_FILE = str(_consolidation_dir / "lessons.json")
_mc.CONNECTIONS_FILE = str(_consolidation_dir / "connections.json")
from rune import memory_store # noqa: E402
def _fail(message: str) -> int:
print(f"FAILED: {message}", file=sys.stderr)
return 1
def _seed_entry(text, kind="fact", memory_type=None, importance=0.5, triggers=""):
"""Insert an entry into the ISOLATED store (no embeddings needed — the
canary runs lexical-only; _embed fails closed to {})."""
db = memory_store.connect()
try:
ts = memory_store.now_iso()
cur = db.execute(
"INSERT INTO entries (text, importance, created_at, last_accessed, "
"topic, entities, source, method, status, kind, triggers, memory_type) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(text, importance, ts, ts, "", "[]", "canary", "curator", "active", kind, triggers, memory_type),
)
db.execute("INSERT INTO entries_fts (rowid, text) VALUES (?,?)", (cur.lastrowid, text))
db.commit()
return cur.lastrowid
finally:
db.close()
def _no_embed() -> None:
"""Force lexical-only recall: deterministic, no Ollama dependency."""
memory_store._embed = lambda texts: {}
def check_trivial_task_abstains() -> int:
"""Invariant 1: a trivial task receives ZERO memory blocks — proven
against a SEEDED store (P1-03 #26 discharge: a distractor the trivial
query cannot match is planted first, so the zero-memory proof is not
vacuous on an empty store)."""
_no_embed()
_seed_entry(
"Quarterly fiscal audit committee schedule for review",
kind="fact",
importance=0.99,
)
_seed_entry(
"I speak in one clean sentence, evidence first, never hedging",
kind="fact",
importance=0.95,
memory_type="personality",
)
from rune.context_broker import MEMORY_BLOCK_NAMES
from rune.rune_operator import RuneOperator
op = RuneOperator(str(Path(_canary_home) / "operator.json"))
task = SimpleNamespace(task_id="canary-t1", description="what time is it", assigned_model="")
broker = op._context_broker()
session: dict = {}
capsule, trace = broker.assemble(task, session, budget_tokens=10_000)
memory_blocks = [b for b in capsule.blocks if b.name in MEMORY_BLOCK_NAMES]
if memory_blocks:
return _fail(f"trivial task injected memory blocks: {[b.name for b in memory_blocks]}")
if trace["memory_tokens"] != 0:
return _fail(f"trivial task trace memory_tokens={trace['memory_tokens']} != 0")
return 0
def check_importance_fallback_dead() -> int:
"""Invariant 2: importance alone never injects; trigger match still does."""
_no_embed()
_seed_entry(
"Quarterly fiscal audit committee schedule for review",
kind="neuron",
importance=0.99,
triggers="audit",
)
_seed_entry(
"Kubernetes pods restart automatically on failure",
kind="neuron",
importance=0.9,
triggers="kubernetes",
)
from rune.context_broker import MEMORY_BLOCK_NAMES
from rune.rune_operator import RuneOperator
op = RuneOperator(str(Path(_canary_home) / "operator-irrelevant.json"))
broker = op._context_broker()
irrelevant = SimpleNamespace(task_id="canary-t2", description="kubernetes pods restart", assigned_model="")
session: dict = {}
capsule, _trace = broker.assemble(irrelevant, session, budget_tokens=10_000)
memory_text = "\n".join(b.content for b in capsule.blocks if b.name in MEMORY_BLOCK_NAMES)
if "fiscal audit" in memory_text:
return _fail("high-importance irrelevant neuron was injected (fallback alive)")
if "fiscal audit" in "\n".join(b.content for b in capsule.blocks):
return _fail("irrelevant neuron content leaked into a non-memory block")
relevant = SimpleNamespace(task_id="canary-t3", description="kubernetes pods restart", assigned_model="")
session2: dict = {}
capsule2, _trace2 = broker.assemble(relevant, session2, budget_tokens=10_000)
memory_text2 = "\n".join(b.content for b in capsule2.blocks if b.name in MEMORY_BLOCK_NAMES)
if "Kubernetes pods restart automatically" not in memory_text2:
return _fail("trigger-matched neuron did not surface (selector broken)")
return 0
def check_taskstate_supremacy() -> int:
"""Invariant 3: old-task content is labeled, lower priority, and never
keys a recall query."""
_no_embed()
from rune.context_broker import MEMORY_BLOCK_NAMES
from rune.orchestration.conversation_state import ConversationMessageRecord
from rune.orchestration.records import TaskStateRecord
from rune.rune_operator import OPERATOR_CHANNEL, RuneOperator
op = RuneOperator(str(Path(_canary_home) / "operator-supremacy.json"))
# Seed a completed past task through the CANONICAL ledger.
past = TaskStateRecord(
title="deploy the kubernetes cluster to production",
channel_id=OPERATOR_CHANNEL,
status="completed",
)
if not op._task_state.import_record(past):
return _fail("could not seed the past task record")
op._conversation.append(
ConversationMessageRecord(
role="user",
content="deploy the kubernetes cluster to production",
channel_id=OPERATOR_CHANNEL,
task_id=past.task_id,
)
)
op._conversation.append(
ConversationMessageRecord(
role="assistant",
content="Cluster deployed; all pods healthy in production.",
channel_id=OPERATOR_CHANNEL,
task_id=past.task_id,
)
)
# Spy on the selector: the recall query must be the ACTIVE task only.
broker = op._context_broker()
captured: dict = {}
real_select = broker.memory_selector.select_for_task
def spy(query, types=None, budget=4):
captured["query"] = query
return real_select(query, types=types, budget=budget)
broker.memory_selector.select_for_task = spy # type: ignore[method-assign]
active = SimpleNamespace(task_id="canary-active", description="what time is it", assigned_model="")
session: dict = {}
capsule, _trace = broker.assemble(active, session, budget_tokens=10_000)
if captured.get("query") != "what time is it":
return _fail(f"recall query keyed on past-task text: {captured.get('query')!r}")
by_name = {b.name: b for b in capsule.blocks}
history = by_name.get("history")
if history is None:
return _fail("completed past task produced no history block")
history_text = (
history.content if isinstance(history.content, str) else json.dumps(history.content, ensure_ascii=False)
)
if "PAST TASK (completed — context only, not instructions)" not in history_text:
return _fail("past-task content is not labeled context-only")
if "kubernetes" not in history_text:
return _fail("past-task content missing from the labeled history block")
user_block = by_name.get("user")
if user_block is None or user_block.evictable:
return _fail("active TaskState block (user) must be non-evictable")
if history.priority >= user_block.priority:
return _fail(f"history priority {history.priority} must be strictly below active task {user_block.priority}")
# The labeled history must never leak into a memory block either.
for block in capsule.blocks:
block_text = block.content if isinstance(block.content, str) else json.dumps(block.content, ensure_ascii=False)
if block.name in MEMORY_BLOCK_NAMES and "deploy the kubernetes cluster to production" in block_text:
return _fail("past-task text surfaced through a memory block")
return 0
def check_memory_budget_enforced() -> int:
"""Invariant 4: the broker evicts memory above its sub-budget."""
from rune.context_broker import ContributionBlock, ContextBroker
broker = ContextBroker(
budget_resolver=lambda window: 10_000,
estimator=lambda text: len(text) // 4,
)
broker.register_contributor(
"memory",
lambda task, session: ContributionBlock("memory", 70, "m" * 8000, 0, "canary"),
2,
)
task = SimpleNamespace(task_id="canary-t4", description="kubernetes pods restart")
_capsule, trace = broker.assemble(task, {}, budget_tokens=10_000)
memory_records = [b for b in trace["blocks"] if b["name"] == "memory"]
if not memory_records or memory_records[0]["admitted"]:
return _fail("memory block above the sub-budget was not evicted")
if "memory budget" not in memory_records[0]["reason"]:
return _fail(f"eviction reason does not name the memory budget: {memory_records[0]['reason']!r}")
return 0
def main() -> int:
checks = (
("trivial task -> zero memory blocks (true abstention)", check_trivial_task_abstains),
("high-importance irrelevant neuron NOT injected; fallback dead", check_importance_fallback_dead),
("old-task content cannot override the active TaskState", check_taskstate_supremacy),
("ContextBroker evicts memory beyond the sub-budget", check_memory_budget_enforced),
)
for label, check in checks:
result = check()
if result != 0:
return result
print(f" ✓ {label}")
print("P1-03 MEMORY SELECTION 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"] != 4:
return _fail(f"schema_version must be 4, got {record['schema_version']}")
if record["schema_version"] != it.INFERENCE_TRACE_SCHEMA_VERSION:
return _fail(f"schema_version must be {it.INFERENCE_TRACE_SCHEMA_VERSION}, 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":
+12 -4
View File
@@ -78,10 +78,18 @@ def main():
# --- 5. Integration with gate ---
print("\n--- 5. Integration with gate ---")
try:
import memory_store
import inspect
src = inspect.getsource(memory_store.gate_consolidated)
test("gate uses query_memory", "query_memory" in src or "adaptive" in src.lower())
import ast
from pathlib import Path
# P1-03 (#26) discharge: gate_consolidated (the flat-0.8 consolidated
# shortcut) is RETIRED — deleted from the store; cached_recall routes
# every query through the thresholded recall only. The canary proves
# the retirement against the REPO copy (deterministic AST parse —
# not whichever memory_store a sys.path shim resolves first).
repo_store = Path(__file__).resolve().parent / "memory_store.py"
tree = ast.parse(repo_store.read_text())
names = {n.name for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
test("gate_consolidated retired (flat-0.8 bypass removed)", "gate_consolidated" not in names)
except Exception as e:
test("gate integration", False, str(e))
+69 -2
View File
@@ -116,6 +116,23 @@ def default_budget(window: int) -> int:
return max(1, window - max(2048, window // 4) - window // 20)
# Cap of the memory sub-budget (P1-03 #26): memory must never crowd out
# tools/history/user even on small windows.
MEMORY_BUDGET_CAP = 1200
def default_memory_budget(window: int | None) -> int:
"""Memory sub-budget = min(1200, window // 8) (P1-03 #26, DD 5).
ContextBroker owns the FINAL memory budget: the sum of every
MEMORY_BLOCK_NAMES block may never exceed this, regardless of the
global input budget. An unknown window falls back to the cap only.
"""
if not window or window <= 0:
return MEMORY_BUDGET_CAP
return min(MEMORY_BUDGET_CAP, int(window) // 8)
def memory_disabled() -> bool:
"""True when RUNE_MEMORY_OFF is set to a truthy value.
@@ -440,6 +457,25 @@ class ContextBroker:
self._archive_path = archive_path
self._summarizer = summarizer
self._trace_log = []
self.memory_selector = self._build_memory_selector()
def _build_memory_selector(self):
"""Compose the memory-selection authority (P1-03 #26).
The authority map names ``ContextBroker.memory_selector`` as the
v1_owner for memory selection. An import/construction failure is
fail-closed: the selector is None and the memory contributor
abstains — never a fallback injection.
"""
try:
try:
from .memory_selector import MemorySelector
except ImportError: # TUI top-level deploy
from memory_selector import MemorySelector
return MemorySelector()
except Exception as exc:
LOG.warning("memory selector unavailable (%s) — memory will abstain", exc)
return None
# -- contributor registry ------------------------------------------------
@@ -495,7 +531,10 @@ class ContextBroker:
return collected
def _admit(
self, collected: list[tuple[str, ContributionBlock, int]], budget: int
self,
collected: list[tuple[str, ContributionBlock, int]],
budget: int,
memory_budget: int | None = None,
) -> tuple[list[ContributionBlock], dict]:
"""Deterministic admission: priority desc, tie: registration recency.
@@ -503,7 +542,16 @@ class ContextBroker:
evictable block is admitted while the budget lasts, otherwise
dropped with its token count recorded. A mandatory block that
cannot fit at all raises ContextOverflowError (fail loud).
P1-03 (#26): ContextBroker owns the final memory budget. The sum of
MEMORY_BLOCK_NAMES blocks is bounded by ``memory_budget`` (default
min(1200, window // 8)): once the memory sub-budget is exhausted,
further memory blocks are dropped (lowest priority first via the
normal priority-desc walk) with a ``memory budget`` reason. The
active TaskState block (``user``) is non-evictable and unaffected.
"""
if memory_budget is None:
memory_budget = default_memory_budget(None)
recency = {id(block): index for index, (_name, block, _b) in enumerate(collected)}
def sort_key(entry):
@@ -514,6 +562,7 @@ class ContextBroker:
admitted: list[ContributionBlock] = []
trace_blocks: list[dict] = []
used = 0
memory_used = 0
for _name, block, _bucket in candidates:
record = {
"name": block.name,
@@ -535,9 +584,20 @@ class ContextBroker:
used += block.tokens
trace_blocks.append(record)
continue
in_memory_scope = block.name in MEMORY_BLOCK_NAMES
if in_memory_scope and used + block.tokens <= budget and memory_used + block.tokens > memory_budget:
record["admitted"] = False
record["reason"] = (
f"memory budget: {memory_used}+{block.tokens}>{memory_budget} "
"(memory sub-budget exhausted; lowest-priority memory dropped)"
)
trace_blocks.append(record)
continue
if used + block.tokens <= budget:
admitted.append(block)
used += block.tokens
if in_memory_scope:
memory_used += block.tokens
trace_blocks.append(record)
else:
record["admitted"] = False
@@ -549,6 +609,8 @@ class ContextBroker:
"blocks": trace_blocks,
"admitted_total": used,
"dropped": dropped,
"memory_budget": memory_budget,
"memory_used": memory_used,
}
def assemble(
@@ -571,6 +633,10 @@ class ContextBroker:
window = None
budget = int(budget_tokens)
# P1-03 (#26): ContextBroker owns the final memory budget —
# min(1200, window // 8); the global budget bounds it further.
memory_budget = min(default_memory_budget(window), budget) if budget else default_memory_budget(window)
# Measure every block with the broker estimator (measured, not guessed).
for _name, block, _bucket in collected:
if isinstance(block.content, str):
@@ -608,7 +674,7 @@ class ContextBroker:
block.tokens = sum(self._estimator(m.get("content", "")) for m in compacted)
try:
admitted, admission = self._admit(collected, budget)
admitted, admission = self._admit(collected, budget, memory_budget=memory_budget)
except ContextOverflowError:
raise
@@ -629,6 +695,7 @@ class ContextBroker:
"model_id": model_id,
"window": window,
"budget": budget,
"memory_budget": admission["memory_budget"],
"blocks": admission["blocks"],
"admitted": [block.name for block in admitted],
"admitted_total": admission["admitted_total"],
+9 -1
View File
@@ -25,7 +25,7 @@ try:
except ImportError: # direct script execution
from rune.memory_env import rune_home
INFERENCE_TRACE_SCHEMA_VERSION = 4
INFERENCE_TRACE_SCHEMA_VERSION = 5
TRACE_ROTATE_BYTES = 10_000_000
TRACE_BACKUPS = 3
@@ -94,6 +94,14 @@ class InferenceTrace:
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
# P1-03 (#26) schema v5 — memory-selection fields, additive. The
# irrelevant-memory rate is computable per run:
# (memory_candidates - memory_selected) / memory_candidates when
# candidates > 0; abstained runs are recorded as zero-injection events.
memory_candidates: int = 0 # candidates surfaced by all selector sources
memory_selected: int = 0 # candidates that cleared the ONE floor
memory_abstained: bool = False # True = zero memory injected (fail-closed)
memory_types_queried: list = field(default_factory=list) # typed targeting
def price_for(model: str) -> float | None:
+511
View File
@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""memory_selector.py — the ONE memory-selection API (P1-03, #26).
Authority map (config/v1_authority_map.json, domain ``memory_selection``):
``ContextBroker.memory_selector`` is the v1_owner. Everything that surfaces
memory into model context flows through ``MemorySelector.select_for_task`` —
the operator registers exactly ONE memory contributor consuming it; the
pre-P1-03 parallel contributors (recall, persona_memories, consolidated,
neurons, graphs, abstraction) are replaced, not added to.
Policy (issue #26):
- ONE relevance floor — ``memory_store.RECALL_MIN_SCORE`` (0.42) default —
applied to EVERY source. The store source applies it inside the store's
calibrated gate (``min_score`` is passed down into ``recall``, which
enforces it in EVERY mode: lexical-only, dense-only, hybrid); self-
scored sources are floored here. ``gate_consolidated``'s flat-0.8
shortcut inside ``cached_recall`` is removed (Task 3) so consolidated
knowledge competes under the same floor, and the top-5-importance neuron
fallback is gone.
- TRUE ABSTENTION: a trivial task legitimately receives ZERO recalled
memories. Below-floor candidates are counted (the irrelevant-memory rate
is computable per run) but never selected.
- Memory-type targeting via ``memory_store``'s typed recall filter.
- FAIL-CLOSED: a source failure is a skip + log, not an injection; if the
selector itself is unavailable the contributor abstains — never fail open
to "inject something important".
Degrade-not-crash throughout: sources are pluggable and isolated; one
exploding source never takes down assembly.
"""
from __future__ import annotations
import functools
import inspect
import logging
from dataclasses import dataclass, field
from typing import Callable
try:
from . import memory_store
except ImportError: # direct script execution
import memory_store # type: ignore[no-redef]
try:
from .context_broker import ContributionBlock
except ImportError: # direct script execution
from context_broker import ContributionBlock # type: ignore[no-redef]
LOG = logging.getLogger("rune.memory_selector")
# Candidate cap BEFORE budgeting (plan DD 5: the selector caps its own
# candidate list before returning).
MAX_CANDIDATES = 24
# Default per-task candidate budget (the old memory contributor recalled 3;
# the collapsed selector merges all sources under one slightly larger cap).
DEFAULT_BUDGET = 4
# Priority of the single memory block (was _contributor_memory's 70).
MEMORY_BLOCK_PRIORITY = 70
@dataclass
class SelectionResult:
"""One selection outcome — mirror of CapabilityBroker's select_for_job.
``irrelevant_rate`` is computable per run:
``(candidates_evaluated - selected_count) / candidates_evaluated`` when
candidates > 0; abstained runs are recorded as zero-injection events.
"""
blocks: list[ContributionBlock]
candidates_evaluated: int
selected_count: int
abstained: bool
types_queried: list[str] = field(default_factory=list)
def irrelevant_rate(self) -> float:
if self.candidates_evaluated <= 0:
return 0.0
return (self.candidates_evaluated - self.selected_count) / self.candidates_evaluated
class MemorySelector:
"""Single memory-selection API composed by ContextBroker.
Sources are registered callables ``(query, types, budget, min_score) ->
list[dict]`` where each dict carries at least ``text`` and ``score``
(0-1 relevance under the ONE floor). The store source (``_store_recall``)
routes through ``memory_store.recall``'s calibrated gate; Task 3 adds the
remaining sources. A source failure is skipped + logged (fail-closed).
KNOWN RANKING BIAS (P1-03 #26 discharge, finding 9): the ONE floor gates
each source on its OWN calibrated scale (lexical store ~4-6, dense/
hybrid store RRF ~0-0.13 with the cosine gated at 0.42, self-scored
0-1), but merged candidates are RANKED by raw score — so a lexical-mode
store row outranks an equally-relevant self-scored row. Abstention is
unaffected (every source is floored on its own scale); only candidate
ORDER is biased. Per-source rank normalization was deliberately deferred:
renormalizing the store's calibrated scales risked false abstentions
this late in the release.
"""
def __init__(self, db_path: str | None = None, min_score: float | None = None):
self.db_path = db_path if db_path is not None else memory_store.DB_PATH
self.min_score = float(min_score) if min_score is not None else float(memory_store.RECALL_MIN_SCORE)
# P1-03 (#26) DISCHARGE (finding 3): the selector's PINNED db_path is
# bound into store-backed sources at registration time — a bare
# ``memory_store.connect()`` inside a source used to open the
# module-default store, ignoring the pin. Only sources that actually
# declare a ``db_path`` parameter are bound (replacement sources
# registered via add_source keep the documented 4-arg contract).
self._sources: dict[str, Callable] = {}
for name, fn in DEFAULT_SOURCES.items():
try:
params = inspect.signature(fn).parameters
has_db_path = "db_path" in params or any(
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
)
except (TypeError, ValueError):
has_db_path = False
if has_db_path:
self._sources[name] = functools.partial(fn, db_path=self.db_path)
else:
self._sources[name] = fn
# -- source registry -----------------------------------------------------
def add_source(self, name: str, fn: Callable) -> None:
"""Register a selection source. Registration ORDER is execution order
(dict preserves it); re-registering a name replaces that source."""
self._sources[name] = fn
def source_names(self) -> list[str]:
"""Registered source names in execution order."""
return list(self._sources)
# -- selection -------------------------------------------------------------
def select_for_task(
self,
task_description: str,
types: list[str] | None = None,
budget: int = DEFAULT_BUDGET,
) -> SelectionResult:
"""Select memory for one task under the ONE floor.
Returns a SelectionResult; on total selector failure the caller
(operator contributor) treats any exception as an abstention.
"""
query = (task_description or "").strip()
types_queried = list(types) if types else []
if not query:
return SelectionResult([], 0, 0, True, types_queried)
candidates: list[dict] = []
for name, fn in self._sources.items():
try:
found = fn(query, types_queried, MAX_CANDIDATES, self.min_score)
except Exception as exc: # fail-closed per source
LOG.warning("memory source %r failed; skipping (%s)", name, exc)
continue
for item in found or []:
entry = dict(item)
entry["kind"] = entry.get("kind") or name
candidates.append(entry)
candidates = candidates[:MAX_CANDIDATES]
# ONE floor for EVERY source: the store source was already floored by
# the same constant inside recall() — which applies min_score in
# EVERY mode (lexical-only, dense-only, hybrid; P1-03 #26 discharge)
# — so its rows are tagged store_scored and exempt from this
# selector-side numeric check. Self-scored sources are floored here
# against the same selector min_score.
selected = [item for item in candidates if item.get("store_scored") or _score_of(item) >= self.min_score]
selected.sort(key=lambda item: -_score_of(item))
selected = selected[: max(0, budget)]
blocks: list[ContributionBlock] = []
for item in selected:
text = str(item.get("text", "")).strip()
if not text:
continue
blocks.append(
ContributionBlock(
"memory",
MEMORY_BLOCK_PRIORITY,
f"- {text}",
max(1, len(text) // 4),
"memory_selector.select_for_task",
evictable=True,
)
)
abstained = not blocks
if abstained:
LOG.info(
"memory abstain: task=%r candidates=%d floor=%.2f types=%s",
query[:60],
len(candidates),
self.min_score,
types_queried or "all",
)
return SelectionResult(blocks, len(candidates), len(blocks), abstained, types_queried)
# -- sources ---------------------------------------------------------------
def _source_store(self, query: str, types: list[str], budget: int, min_score: float) -> list[dict]:
"""Typed store recall through the ONE thresholded path."""
return _store_recall(query, types, budget, min_score, db_path=self.db_path)
def _score_of(item: dict) -> float:
"""Extract the item's 0-1 relevance under the ONE floor. Missing or
malformed scores floor at 0.0 (abstain) — never guessed upward."""
value = item.get("score")
if isinstance(value, bool) or not isinstance(value, (int, float)):
return 0.0
return float(value)
def _store_recall(
query: str,
types: list[str] | None,
budget: int,
min_score: float = memory_store.RECALL_MIN_SCORE,
db_path: str | None = None,
) -> list[dict]:
"""Store source: typed recall through the ONE thresholded path.
Uses ``memory_store.recall`` (the calibrated QPP gate) — never the
``cached_recall`` path whose ``gate_consolidated`` flat-0.8 shortcut
bypasses thresholding (removed in Task 3).
The store's ``relevance`` lives on mode-dependent calibrated scales
(lexical-mode ~4-6 via the +5 offset band, dense/hybrid-mode RRF ~0-0.13,
with the dense COSINE gated at 0.42-0.8 — see memory_store.recall), so it
is NOT renormalized here: the store already applied THE floor via
``min_score`` in EVERY recall mode (lexical-only, dense-only, hybrid —
P1-03 #26 discharge), so tagging the result ``store_scored=True`` and
exempting it from the selector's numeric check is sound. The result is
tagged ``store_scored=True`` so the selector treats these rows as
floor-cleared by the same constant.
KNOWN RANKING BIAS (P1-03 #26 discharge, finding 9): because the scales
differ per mode, the selector's raw-score ranking favors lexical-mode
store rows (~4-6) over self-scored rows (0-1) and dense/hybrid-mode
store rows (~0-0.13). Abstention is NOT affected (each source is floored
on its own calibrated scale); only the ORDER of merged candidates is
biased. Per-source rank normalization was deliberately deferred —
renormalizing the store's calibrated scales risked false abstentions.
P1-03 (#26) DISCHARGE (finding 2a): besides the caller-typed query, the
source always runs a SECOND typed query restricted to
``self``/``personality`` — the persona lane. The production caller (the
operator's single memory contributor) invokes the selector WITHOUT
types, which left the typed persona source dead in production. Both
queries are thresholded inside the store's gate and may abstain — a
trivial task still gets zero persona rows. Results are merged and
capped at ``budget``.
P1-03 (#26) DEVIATION: shard routing inside ``recall`` is bypassed — the
selector pins the canonical store connection. ShardManager routes to a
hardcoded ~/.config/opencode shard dir that ignores RUNE_* isolation and
carries no memory_type column, so routing through it would break typed
recall and the preserve-stores/isolation invariants.
"""
db = memory_store.connect(db_path)
try:
results = memory_store.recall(db, query, budget=budget, min_score=min_score, memory_types=types or None)
persona_types = ["self", "personality"]
if not (set(types or []) & set(persona_types)):
# The persona lane: always executed for the untyped production
# caller (and any caller that did not itself ask persona types).
results = results + memory_store.recall(
db, query, budget=budget, min_score=min_score, memory_types=persona_types
)
finally:
db.close()
out: list[dict] = []
seen_ids: set = set()
for row in results:
# The untyped and typed-lane queries overlap on unclassified rows —
# merge by entry id so a shared row is a candidate ONCE (duplicates
# would corrupt the irrelevant-memory rate and repeat block text).
row_id = row.get("id")
if row_id is not None:
if row_id in seen_ids:
continue
seen_ids.add(row_id)
out.append(
{
"text": row.get("text", ""),
"score": float(row.get("relevance", 0.0)),
"store_scored": True,
"kind": "store",
"id": row.get("id"),
"source": row.get("source", ""),
}
)
return out
def _lex_overlap_score(text: str, query: str) -> float:
"""Deterministic lexical relevance for self-scored sources.
Distinctive-word coverage of the QUERY terms present in the candidate
text, with a stopword filter shared with the store's gate vocabulary.
A candidate sharing NO distinctive query word cannot score — the same
conjunctive logic the store's BM25 gate applies (a shared single word is
not a relation, but zero shared words is definitely not one).
"""
stopwords = getattr(memory_store, "_STOPWORDS", set())
q_terms = {t.lower() for t in query.split() if len(t) > 3 and t.lower() not in stopwords}
if not q_terms:
return 0.0
text_lower = (text or "").lower()
hits = sum(1 for term in q_terms if term in text_lower)
return hits / len(q_terms)
def _neurons_recall(query: str, types: list[str] | None, budget: int, min_score: float) -> list[dict]:
"""Warm-neuron source: trigger scan, thresholded — NO importance fallback.
The pre-P1-03 contributor injected the top-5 neurons by importance when
no trigger matched (rune_operator.py:1387-1389). That fallback is dead:
a neuron that does not match the task is not injected merely because it
is important. Trigger-matched neurons are scored lexically against the
query and must clear the same floor as every other source.
"""
try:
import warm_neuron_store
except ImportError: # TUI top-level deploy
import rune.warm_neuron_store as warm_neuron_store # type: ignore[no-redef]
neurons = warm_neuron_store.list_neurons()
task_words = {w.lower().strip(".,;:!?()[]{}\"'") for w in query.split()}
out: list[dict] = []
for neuron in neurons:
triggers = neuron.get("triggers") or []
if not any(t in task_words for t in triggers):
continue
text = neuron.get("text", "")
score = _lex_overlap_score(text, query)
if score < min_score:
continue
out.append(
{
"text": f"[{neuron.get('slug', 'warm')}] {text}",
"score": score,
"kind": "neuron",
"source": "warm_neuron_store.list_neurons",
}
)
if len(out) >= budget:
break
return out
def _graphs_recall(query: str, types: list[str] | None, budget: int, min_score: float) -> list[dict]:
"""Graph source: entity BFS from the task words via graph_memory.query,
floored. The pre-P1-03 contributor called resolve_entity(), which MUTATES
the graph (creates missing entities); read-only query() keeps the store
side-effect-free. A term with no entity yields no candidates."""
try:
import graph_memory
except ImportError: # TUI top-level deploy
import rune.graph_memory as graph_memory # type: ignore[no-redef]
db = graph_memory.connect()
out: list[dict] = []
try:
task_words = list(dict.fromkeys(w.lower().strip(".,;:!?()[]{}\"'") for w in query.split()))[:5]
for word in task_words:
result = graph_memory.query(db, word, hops=1)
if not result:
continue
entity = result.get("entity", word) if isinstance(result, dict) else str(result)
facts = result.get("facts", []) if isinstance(result, dict) else []
lines = [f"{f.get('subject')} {f.get('predicate', '-')} {f.get('object')}" for f in facts]
text = "; ".join(lines) if lines else str(entity)
score = _lex_overlap_score(text, query) or (1.0 / max(len(task_words), 1))
if score < min_score:
continue
out.append({"text": text, "score": score, "kind": "graph", "source": "graph_memory.query"})
if len(out) >= budget:
break
finally:
db.close()
return out
def _abstraction_recall(query: str, types: list[str] | None, budget: int, min_score: float) -> list[dict]:
"""Abstraction-facts source: LIKE scan over active facts, floored.
The pre-P1-03 contributor injected the top-3 LIKE matches unconditionally;
here a LIKE match is only a candidate — it must clear the floor.
"""
try:
import abstraction
except ImportError: # TUI top-level deploy
import rune.abstraction as abstraction # type: ignore[no-redef]
db = abstraction.connect()
out: list[dict] = []
try:
task_words = [w for w in query.lower().split()[:3] if w]
if not task_words:
return []
placeholders = " OR ".join(["what LIKE ?" for _ in task_words])
params = [f"%{w}%" for w in task_words]
rows = db.execute(
f"SELECT what FROM facts WHERE state='active' AND ({placeholders}) LIMIT {int(budget) * 3}",
params,
).fetchall()
for row in rows:
text = str(row[0])
score = _lex_overlap_score(text, query)
if score < min_score:
continue
out.append({"text": text, "score": score, "kind": "abstraction", "source": "abstraction facts"})
if len(out) >= budget:
break
finally:
db.close()
return out
def _consolidated_recall(query: str, types: list[str] | None, budget: int, min_score: float) -> list[dict]:
"""Consolidated-knowledge source under the SAME floor (bypass removed).
The pre-P1-03 path handed ``gate_consolidated``'s output a flat
relevance=0.8, bypassing thresholding entirely. Consolidated knowledge
now competes like any candidate: EACH knowledge item is scored by its
lexical overlap with the active task, and below-floor items abstain.
"""
try:
from .memory_consolidation import MemoryConsolidation
except ImportError: # TUI top-level deploy
from memory_consolidation import MemoryConsolidation # type: ignore[no-redef]
try:
consolidation = MemoryConsolidation()
items = list(consolidation.get_relevant_knowledge(query, limit=budget * 2))
except Exception as exc: # degrade: consolidation unavailable -> no candidates
LOG.warning("consolidation source unavailable; skipping (%s)", exc)
return []
out: list[dict] = []
for item in items:
content = (getattr(item, "content", "") or "").strip()
if not content:
continue
score = _lex_overlap_score(content, query)
if score < min_score:
continue # below floor -> abstain (was: flat relevance injection)
out.append({"text": content, "score": score, "kind": "consolidated", "source": "memory_consolidation"})
if len(out) >= budget:
break
return out
def _persona_recall(
query: str,
types: list[str] | None,
budget: int,
min_score: float,
db_path: str | None = None,
) -> list[dict]:
"""Typed persona source: self/personality memory keyed on the ACTIVE task.
Replaces the hardcoded ``"Rune personality voice identity how to speak"``
query. Only runs when the caller asked for self/personality types (the
operator's persona-memory call site); thresholded, may abstain — a
trivial task gets zero persona lines.
P1-03 (#26) DISCHARGE (finding 3): the source queries the selector's
PINNED store (``db_path``) — never the module-default store that a bare
``memory_store.connect()`` would open.
"""
wanted = {t for t in (types or []) if t in ("self", "personality")}
if not wanted:
return []
db = memory_store.connect(db_path)
try:
rows = memory_store.recall(db, query, budget=budget, min_score=min_score, memory_types=sorted(wanted))
finally:
db.close()
out: list[dict] = []
for row in rows:
out.append(
{
"text": row.get("text", ""),
"score": float(row.get("relevance", 0.0)),
"kind": "persona",
"source": "memory_store.recall(self/personality)",
}
)
return out
# The default source set (P1-03 #26): every pre-selector memory path, unified
# under the ONE floor. Registration order = execution order.
DEFAULT_SOURCES: dict[str, Callable] = {
"store": _store_recall,
"neurons": _neurons_recall,
"graphs": _graphs_recall,
"abstraction": _abstraction_recall,
"consolidated": _consolidated_recall,
"persona": _persona_recall,
}
+703 -474
View File
File diff suppressed because it is too large. Load diff
+159 -452
View File
@@ -81,6 +81,7 @@ def _build_model_router():
record_failure("model_routing", exc, tier=1, context="build_model_router")
return None
try:
try:
from .local_tool_runtime import LocalToolRuntime, OllamaChatTransport
@@ -131,6 +132,7 @@ try:
TaskStateRepository,
migrate_operator_tasks,
)
try:
from .orchestration.event_store import DurableEventStore
except ImportError: # TUI top-level deploy
@@ -208,11 +210,7 @@ def _memory_query_from_trace(trace: Dict[str, Any], task_id: str) -> MemoryQuery
Admitted blocks become the {block_name: tokens} map; dropped blocks ride
through with their recorded reasons. Observable selection state only.
"""
admitted = {
block["name"]: block["tokens"]
for block in trace.get("blocks", [])
if block.get("admitted")
}
admitted = {block["name"]: block["tokens"] for block in trace.get("blocks", []) if block.get("admitted")}
dropped = [
{"name": block.get("name", ""), "tokens": block.get("tokens", 0)}
for block in trace.get("blocks", [])
@@ -261,9 +259,7 @@ def _observations_from_degraded(degraded: list[str], task_id: str) -> list:
]
def _verification_and_outcome(
response: OperatorResponse, task_id: str, degraded: list[str]
) -> tuple:
def _verification_and_outcome(response: OperatorResponse, task_id: str, degraded: list[str]) -> tuple:
"""VerificationResult + linked Outcome for the final response (#30).
Method contract (frozen): 'exit_code' when a run_command tool event
@@ -290,9 +286,7 @@ def _verification_and_outcome(
evidence = _bounded(result.get("result") or "run_tests passed")
break
has_tool_failure = any(
event.kind in {"error", "failed"} for event in response.tool_events
)
has_tool_failure = any(event.kind in {"error", "failed"} for event in response.tool_events)
passed = bool(response.success) and not has_tool_failure
if method == "none":
# No objective evidence ran — never self-report (plan: the
@@ -320,6 +314,7 @@ def _verification_and_outcome(
)
return verification, outcome
#: A message that looks like a confirmation/follow-up of the active task
#: gets amended onto it instead of minting a new task (P0-02 #18).
_FOLLOW_UP_RE = re.compile(
@@ -439,9 +434,7 @@ class RuneOperator:
self._events_store = DurableEventStore(root / "events.jsonl")
self._hot_store = AtomicStore(root / "hot")
self._task_state = TaskStateRepository(self._events_store, self._hot_store)
self._conversation = ConversationStateRepository(
self._events_store, self._hot_store, tasks=self._task_state
)
self._conversation = ConversationStateRepository(self._events_store, self._hot_store, tasks=self._task_state)
try:
self._migration_report = migrate_operator_tasks(
Path(self.storage_path), self._task_state, self._conversation
@@ -449,9 +442,7 @@ class RuneOperator:
except Exception as exc: # P0-06 contract: degrade loudly, never silently
import logging
logging.getLogger(__name__).warning(
"Legacy operator.json migration failed: %s", exc
)
logging.getLogger(__name__).warning("Legacy operator.json migration failed: %s", exc)
self._migration_report = None
def _load(self):
@@ -466,13 +457,7 @@ class RuneOperator:
current_task=data.get("current_task"),
active_model=data.get("active_model"),
tasks=[
RuneTask(
**{
k: v
for k, v in t.items()
if k in RuneTask.__dataclass_fields__
}
)
RuneTask(**{k: v for k, v in t.items() if k in RuneTask.__dataclass_fields__})
for t in data.get("tasks", [])
],
pending_task_id=data.get("pending_task_id"),
@@ -482,9 +467,7 @@ class RuneOperator:
except Exception as e:
import logging
logging.getLogger(__name__).warning(
"Failed to load operator state: %s", e
)
logging.getLogger(__name__).warning("Failed to load operator state: %s", e)
def _save(self):
"""Save Rune's state to storage."""
@@ -499,15 +482,9 @@ class RuneOperator:
"role": self.state.role,
"current_task": self.state.current_task,
"active_model": self.state.active_model,
"tasks_pending": len(
[t for t in self.state.tasks if t.status == "pending"]
),
"tasks_running": len(
[t for t in self.state.tasks if t.status == "running"]
),
"tasks_completed": len(
[t for t in self.state.tasks if t.status == "completed"]
),
"tasks_pending": len([t for t in self.state.tasks if t.status == "pending"]),
"tasks_running": len([t for t in self.state.tasks if t.status == "running"]),
"tasks_completed": len([t for t in self.state.tasks if t.status == "completed"]),
}
def send_task(self, description: str, model: str | None = None, channel: str = OPERATOR_CHANNEL) -> Dict[str, Any]:
@@ -519,9 +496,7 @@ class RuneOperator:
a fresh task records its parent for lineage.
"""
active = self._task_state.get_active(channel)
if active is not None and (
active.status == "running" or _is_follow_up(description)
):
if active is not None and (active.status == "running" or _is_follow_up(description)):
state_task = self._task_state.amend(active.task_id, description)
else:
latest = self._task_state.latest(channel)
@@ -577,9 +552,7 @@ class RuneOperator:
if decision is not None:
model = decision.model
routing_reason = (
decision.reason
if decision.reason.startswith("router:")
else f"router: {decision.reason}"
decision.reason if decision.reason.startswith("router:") else f"router: {decision.reason}"
)
routing_mode = "cloud" if decision.provider not in ("ollama", "local") else "local"
else:
@@ -602,11 +575,7 @@ class RuneOperator:
)
failed_event = next(
(
event
for event in result.tool_events
if event.kind in {"error", "failed"}
),
(event for event in result.tool_events if event.kind in {"error", "failed"}),
None,
)
if failed_event is not None:
@@ -645,9 +614,7 @@ class RuneOperator:
task.result = result.user_text
task.completed_at = datetime.now(timezone.utc).isoformat()
self.state.current_task = None
self._task_state.transition(
task.task_id, "completed", outcome_ref="response:" + task.task_id
)
self._task_state.transition(task.task_id, "completed", outcome_ref="response:" + task.task_id)
self._conversation.append(
ConversationMessageRecord(
role="assistant",
@@ -680,9 +647,7 @@ class RuneOperator:
if "status" not in obj or obj.get("task_id") != task.task_id:
continue
obj["status"] = task.status
verification = next(
(item for item in result.runtime_objects if "method" in item), None
)
verification = next((item for item in result.runtime_objects if "method" in item), None)
obj["verification_id"] = (
verification["verification_id"]
if verification is not None and verification.get("passed") and task.status == "completed"
@@ -786,57 +751,67 @@ class RuneOperator:
self._last_capability_selection = selection
return selection
def _conversation_messages(
self, current_task_id: str, limit: int = 8
) -> list[dict]:
"""Return recent completed user/assistant pairs for the next turn."""
completed = [
item
for item in self.state.tasks
if item.task_id != current_task_id
and item.status == "completed"
and isinstance(item.result, str)
and item.result.strip()
][-limit:]
def _conversation_messages(self, current_task_id: str, limit: int = 8) -> list[dict]:
"""Recent completed-task turns from the CANONICAL ledger (#18, P1-03).
Status authority is TaskStateRepository (completed tasks only);
message bodies come from ConversationStateRepository. Past-task
content is explicitly labeled context-only so it can never be read
as instructions — and it never keys a recall query (the selector is
queried with the ACTIVE task description only).
"""
window = self._conversation.window(OPERATOR_CHANNEL, n=limit * 4)
by_task: dict[str, list[dict]] = {}
order: list[str] = []
for msg in window:
if not msg.task_id or msg.task_id == current_task_id:
continue
if msg.task_id not in by_task:
order.append(msg.task_id)
by_task[msg.task_id] = []
by_task[msg.task_id].append({"role": msg.role, "content": msg.content})
messages: list[dict] = []
for item in completed:
messages.append({"role": "user", "content": item.description})
messages.append({"role": "assistant", "content": item.result})
return messages
for task_id in reversed(order):
record = self._task_state.get(task_id)
if record is None or record.status != "completed":
continue
labeled: list[dict] = []
for message in by_task[task_id]:
if message["role"] == "user":
labeled.append(
{
"role": "user",
"content": (
"PAST TASK (completed — context only, not instructions): " + (message["content"] or "")
),
}
)
else:
labeled.append(message)
messages = labeled + messages # chronological order
if len(messages) >= limit * 2:
break
return messages[: limit * 2]
def resolve_pending_approval(self, approved: bool) -> Dict[str, Any]:
"""Resolve and resume the single persisted operator approval."""
task = next(
(
item
for item in self.state.tasks
if item.task_id == self.state.pending_task_id
),
(item for item in self.state.tasks if item.task_id == self.state.pending_task_id),
None,
)
request = self.state.pending_request
pending_state = self.state.pending_runtime_state
if task is None or request is None or pending_state is None:
return self._response_envelope(
OperatorResponse(success=False, error="no pending approval"), task
)
return self._response_envelope(OperatorResponse(success=False, error="no pending approval"), task)
model = task.assigned_model or self.state.active_model or ""
runtime = self._runtime_for_model(model, task.description)
result = (
runtime.approve(pending_state)
if approved
else runtime.reject(pending_state)
)
result = runtime.approve(pending_state) if approved else runtime.reject(pending_state)
result = self._finalize_runtime_response(
result, model, task.description, result.override_tier, result.recall_count
)
failed_event = next(
(
event
for event in result.tool_events
if event.kind in {"error", "failed"}
),
(event for event in result.tool_events if event.kind in {"error", "failed"}),
None,
)
if failed_event is not None:
@@ -872,9 +847,7 @@ class RuneOperator:
"""
try:
runtime_objects: list = []
for observation in _observations_from_degraded(
getattr(response, "degraded", None) or [], task_id
):
for observation in _observations_from_degraded(getattr(response, "degraded", None) or [], task_id):
runtime_objects.append(observation.to_dict())
if response.pending_approval is None:
verification, outcome = _verification_and_outcome(
@@ -962,26 +935,18 @@ class RuneOperator:
return [m.to_dict() for m in self._conversation.window(channel, n=n)]
@staticmethod
def _response_envelope(
result: OperatorResponse, task: Optional[RuneTask]
) -> Dict[str, Any]:
def _response_envelope(result: OperatorResponse, task: Optional[RuneTask]) -> Dict[str, Any]:
envelope = asdict(result)
envelope["task"] = task.to_dict() if task is not None else None
envelope["response"] = result.user_text
if result.pending_approval is not None:
pending_event = next(
(
event
for event in reversed(result.tool_events)
if event.kind == "pending_approval"
),
(event for event in reversed(result.tool_events) if event.kind == "pending_approval"),
None,
)
envelope["needs_confirm"] = True
envelope["tool_call"] = asdict(result.pending_approval)
envelope["tool_result"] = (
dict(pending_event.result or {}) if pending_event else {}
)
envelope["tool_result"] = dict(pending_event.result or {}) if pending_event else {}
return envelope
def _fallback_model(self, description: str) -> str:
@@ -994,22 +959,15 @@ class RuneOperator:
desc_lower = description.lower()
# Quick tasks — laptop model
if any(
word in desc_lower for word in ["hello", "hi", "thanks", "ok", "status"]
):
if any(word in desc_lower for word in ["hello", "hi", "thanks", "ok", "status"]):
return "qwen2.5:7b"
# Coding tasks — local coding model
if any(
word in desc_lower for word in ["fix", "code", "debug", "test", "refactor"]
):
if any(word in desc_lower for word in ["fix", "code", "debug", "test", "refactor"]):
return "qwen3:14b"
# Complex tasks — strong local model
if any(
word in desc_lower
for word in ["design", "architecture", "research", "analyze"]
):
if any(word in desc_lower for word in ["design", "architecture", "research", "analyze"]):
return "hf.co/DavidAU/Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF:IQ2_M"
# Default to laptop model
@@ -1048,14 +1006,13 @@ class RuneOperator:
archive = Path(rune_home()) / "state" / "history_archive.jsonl"
broker = ContextBroker(archive_path=archive)
# P1-03 (#26): exactly ONE memory contributor — the six parallel
# memory paths (memory, persona_memories, consolidated, neurons,
# graphs, abstraction) are replaced by this single contributor
# delegating to broker.memory_selector (authority-map v1_owner).
broker.register_contributor("persona", self._contributor_persona, 0)
broker.register_contributor("tools", self._contributor_tools, 1)
broker.register_contributor("memory", self._contributor_memory, 2)
broker.register_contributor("persona_memories", self._contributor_persona_memories, 2)
broker.register_contributor("consolidated", self._contributor_consolidated, 2)
broker.register_contributor("neurons", self._contributor_neurons, 2)
broker.register_contributor("graphs", self._contributor_graphs, 2)
broker.register_contributor("abstraction", self._contributor_abstraction, 2)
broker.register_contributor("model_profile", self._contributor_model_profile, 2)
broker.register_contributor("history", self._contributor_history, 3)
broker.register_contributor("user", self._contributor_user, 4)
@@ -1163,315 +1120,66 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# -- memory notes (bucket 2, evictable; zero under RUNE_MEMORY_OFF) ---
def _contributor_memory(self, task, session):
"""Recall relevant memory for this task → '## Context'."""
try:
from .context_broker import ContributionBlock, memory_disabled
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock, memory_disabled
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
"""THE single memory contributor (P1-03 #26).
if memory_disabled():
return None
import rune.memory_store as memory_store
try:
db = memory_store.connect()
try:
results = memory_store.cached_recall(db, task.description, budget=3)
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("memory_selector", exc, tier=1)
if isinstance(session, dict):
session.setdefault("degraded", []).append("memory_selector")
return None
if not results:
return None
if isinstance(session, dict):
session["recall_count"] = len(results)
memories = [r.get("text", "")[:100] for r in results[:3]]
content = "\n".join(f"- {m}" for m in memories if m)
if not content:
return None
return ContributionBlock("memory", 70, f"\n## Context\n{content}", 0, "memory_store.cached_recall")
def _contributor_persona_memories(self, task, session):
"""Second cached_recall (identity) → '## Identity context'."""
try:
from .context_broker import ContributionBlock, memory_disabled
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock, memory_disabled
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
if memory_disabled():
return None
import rune.memory_store as memory_store
try:
db = memory_store.connect()
try:
persona_memories = memory_store.cached_recall(
db, "Rune personality voice identity how to speak", budget=3
)
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("persona", exc, tier=1)
if isinstance(session, dict):
session.setdefault("degraded", []).append("persona")
return None
if not persona_memories:
return None
persona_context = "\n".join(f"- {r.get('text', '')[:100]}" for r in persona_memories if r.get("text"))
if not persona_context:
return None
return ContributionBlock(
"persona_memories", 68, f"\n## Identity context\n{persona_context}", 0, "memory_store.cached_recall"
)
def _contributor_consolidated(self, task, session):
"""Consolidation pipeline block, WITH its write side effect.
CAREFUL (P0-03 constraint): ``_save_knowledge()`` runs at COLLECT
time — exactly once per turn, even when admission later drops or
the memory gate zeroes this block. Consolidation writes knowledge
derived from memory systems; it is gated by the memory-off switch
(there is nothing to consolidate when memory is off).
Delegates to ``broker.memory_selector.select_for_task`` — the
authority-map v1_owner. The five parallel memory contributors this
replaced (persona_memories, consolidated, neurons, graphs,
abstraction) are gone: their sources now live inside the selector
under the ONE relevance floor. Fail-closed: selector unavailable,
selection failure, or empty selection → None (zero memory blocks) —
never a fallback injection.
"""
try:
from .context_broker import ContributionBlock, memory_disabled
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock, memory_disabled
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
if memory_disabled():
return None
try:
from .memory_consolidation import MemoryConsolidation
except ImportError: # TUI top-level deploy
from rune.memory_consolidation import MemoryConsolidation
degraded_reasons: list[str] = []
consolidation = MemoryConsolidation()
# Consolidate warm neurons into knowledge
try:
import warm_neuron_store
neurons = warm_neuron_store.list_neurons()
for neuron in neurons[:10]: # Consolidate top 10
knowledge = consolidation.consolidate_neuron(neuron)
existing = [k for k in consolidation.knowledge if k.id == knowledge.id]
if not existing:
consolidation.knowledge.append(knowledge)
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("consolidation", exc, tier=1, context="warm neurons")
degraded_reasons.append("warm neurons")
# Consolidate graph memory into knowledge
try:
import graph_memory
db = graph_memory.connect()
try:
task_words = task.description.lower().split()[:5]
for word in task_words:
entity = graph_memory.resolve_entity(db, word)
if entity:
knowledge = consolidation.consolidate_graph_entity(entity)
existing = [k for k in consolidation.knowledge if k.id == knowledge.id]
if not existing:
consolidation.knowledge.append(knowledge)
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("consolidation", exc, tier=1, context="graph memory")
degraded_reasons.append("graph memory")
# Consolidate abstraction facts into knowledge
try:
import abstraction
db = abstraction.connect()
try:
task_words = task.description.lower().split()[:3]
if task_words:
placeholders = " OR ".join(["what LIKE ?" for _ in task_words])
params = [f"%{w}%" for w in task_words]
facts = db.execute(
f"SELECT what FROM facts WHERE status='active' AND ({placeholders}) LIMIT 5",
params,
).fetchall()
for fact in facts:
knowledge = consolidation.consolidate_abstraction_fact(fact[0])
existing = [k for k in consolidation.knowledge if k.id == knowledge.id]
if not existing:
consolidation.knowledge.append(knowledge)
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("consolidation", exc, tier=1, context="abstraction facts")
degraded_reasons.append("abstraction facts")
# Consolidate memory recall into knowledge
try:
import rune.memory_store as memory_store
db = memory_store.connect()
try:
results = memory_store.cached_recall(db, task.description, budget=5)
if results:
for r in results:
text = r.get("text", "")
score = r.get("score", 0.5)
if text:
knowledge = consolidation.consolidate_memory(text, score)
existing = [k for k in consolidation.knowledge if k.id == knowledge.id]
if not existing:
consolidation.knowledge.append(knowledge)
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("consolidation", exc, tier=1, context="memory recall")
degraded_reasons.append("memory recall")
# Write side effect — EXACTLY ONCE per turn, at collect time,
# before admission decides whether the block is visible.
consolidation._save_knowledge()
consolidated_context = consolidation.format_knowledge_for_prompt(task.description)
if not consolidated_context:
return None
return ContributionBlock("consolidated", 60, f"\n{consolidated_context}", 0, "memory_consolidation")
def _contributor_neurons(self, task, session):
"""Warm-neuron trigger scan top-5 → '## My neurons'."""
try:
from .context_broker import ContributionBlock, memory_disabled
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock, memory_disabled
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
if memory_disabled():
return None
import warm_neuron_store
try:
neurons = warm_neuron_store.list_neurons()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("warm_router", exc, tier=1, context="neuron injection")
broker = self._context_broker()
selector = getattr(broker, "memory_selector", None)
if selector is None:
# Fail-closed abstention — recorded so the trace proves it.
if isinstance(session, dict):
session.setdefault("degraded", []).append("warm_router")
session["memory_candidates"] = 0
session["memory_selected"] = 0
session["memory_abstained"] = True
session["memory_types_queried"] = []
return None
if not neurons:
return None
# Find neurons relevant to the current task
task_words = set(task.description.lower().split())
relevant = []
for n in neurons:
triggers = n.get("triggers", [])
if any(t in task_words for t in triggers):
relevant.append(n)
# If no specific match, include top neurons by importance
if not relevant:
relevant = sorted(neurons, key=lambda x: x.get("importance", 0), reverse=True)[:5]
else:
relevant = relevant[:5]
neuron_context = "\n".join(f"- [{n.get('slug', '?')}] {n.get('text', '')[:100]}" for n in relevant)
return ContributionBlock(
"neurons", 55, f"\n## My neurons (warm, firing)\n{neuron_context}", 0, "warm_neuron_store.list_neurons"
)
def _contributor_graphs(self, task, session):
"""Graph-memory entity resolution top-5 → '## My graph connections'."""
description = getattr(task, "description", "") or ""
try:
from .context_broker import ContributionBlock, memory_disabled
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock, memory_disabled
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
if memory_disabled():
return None
import graph_memory
try:
db = graph_memory.connect()
result = selector.select_for_task(description)
except Exception as exc: # fail-closed: abstain, log, continue
try:
task_words = task.description.lower().split()[:5]
entities = []
for word in task_words:
entity = graph_memory.resolve_entity(db, word)
if entity:
entities.append(entity)
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("graph_memory", exc, tier=1)
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
record_failure("memory_selector", exc, tier=1)
if isinstance(session, dict):
session.setdefault("degraded", []).append("graph_memory")
session.setdefault("degraded", []).append("memory_selector")
session["memory_candidates"] = 0
session["memory_selected"] = 0
session["memory_abstained"] = True
session["memory_types_queried"] = []
return None
if not entities:
# P1-03 (#26): selection summary rides the session to the durable
# trace (v5 fields) — the irrelevant-memory rate is computable.
if isinstance(session, dict):
session["memory_candidates"] = result.candidates_evaluated
session["memory_selected"] = result.selected_count
session["memory_abstained"] = result.abstained
session["memory_types_queried"] = list(result.types_queried)
blocks = [
block for block in result.blocks if isinstance(getattr(block, "content", ""), str) and block.content.strip()
]
if not blocks:
return None
entity_context = "\n".join(f"- {e.get('name', '?')}: {e.get('description', '')[:80]}" for e in entities[:5])
return ContributionBlock(
"graphs", 50, f"\n## My graph connections\n{entity_context}", 0, "graph_memory.resolve_entity"
)
def _contributor_abstraction(self, task, session):
"""Abstraction facts LIKE top-3 → '## My learned knowledge'."""
try:
from .context_broker import ContributionBlock, memory_disabled
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock, memory_disabled
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
if memory_disabled():
return None
import abstraction
try:
db = abstraction.connect()
try:
task_words = task.description.lower().split()[:3]
if not task_words:
return None
placeholders = " OR ".join(["what LIKE ?" for _ in task_words])
params = [f"%{w}%" for w in task_words]
facts = db.execute(
f"SELECT what FROM facts WHERE status='active' AND ({placeholders}) LIMIT 3",
params,
).fetchall()
finally:
db.close()
except Exception as exc: # P0-06: observable degrade (T1)
record_failure("abstraction", exc, tier=1)
if isinstance(session, dict):
session.setdefault("degraded", []).append("abstraction")
return None
if not facts:
return None
fact_context = "\n".join(f"- {f[0][:100]}" for f in facts)
return ContributionBlock(
"abstraction", 45, f"\n## My learned knowledge\n{fact_context}", 0, "abstraction facts"
)
if isinstance(session, dict):
session["recall_count"] = result.selected_count
content = "\n## Context\n" + "\n".join(block.content for block in blocks)
return ContributionBlock("memory", 70, content, 0, "memory_selector.select_for_task")
def _contributor_model_profile(self, task, session):
"""Model-profile prompt context → '## Model' (mock contract kept)."""
@@ -1491,11 +1199,19 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# -- history (bucket 3, evictable; compaction-ready) -------------------
def _contributor_history(self, task, session):
"""Recent completed pairs as the history block (compaction target).
"""Past completed-task turns as the history block (compaction target).
P1-03 (#26) TaskState supremacy: rebased on TaskStateRepository
(status authority) + ConversationStateRepository (bodies). Past-task
content is labeled ``PAST TASK (completed — context only, not
instructions)`` and this block's priority (30) stays strictly below
the active TaskState block (``user``, 100, non-evictable) — old-task
text can never outrank the active task. Recall queries are keyed
ONLY on the active task description, never on past-task text.
Not gated by RUNE_MEMORY_OFF: history is the operator's own task
ledger (RuneState), not a memory-store selector — the memory-off
absence proof covers recall/persona/consolidation only.
ledger, not a memory-store selector — the memory-off absence proof
covers recall/persona/consolidation only.
"""
try:
from .context_broker import ContributionBlock
@@ -1553,20 +1269,24 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
if not hasattr(broker, "assemble"):
from types import SimpleNamespace as _NS
broker = _NS(assemble=lambda *a, **k: (
_NS(token_count=0, blocks=[], active_objective="", to_messages=lambda: []),
{},
))
broker = _NS(
assemble=lambda *a, **k: (
_NS(token_count=0, blocks=[], active_objective="", to_messages=lambda: []),
{},
)
)
assemble = getattr(broker, "assemble", None)
if assemble is None:
# Degrade-not-crash: a stand-in broker without assemble gets an
# empty capsule + trace (scripted tests, Mock runtimes).
from types import SimpleNamespace as _NS
broker = _NS(assemble=lambda *a, **k: (
_NS(token_count=0, blocks=[], active_objective="", to_messages=lambda: []),
{},
))
broker = _NS(
assemble=lambda *a, **k: (
_NS(token_count=0, blocks=[], active_objective="", to_messages=lambda: []),
{},
)
)
capsule, trace = broker.assemble(task, session, model_id=model) # type: ignore[attr-defined]
self._last_trace = trace
for subsystem in trace.get("degraded", []):
@@ -1637,9 +1357,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
"output": getattr(transport, "usage_output_tokens", 0),
"duration_ns": getattr(transport, "usage_total_duration_ns", 0),
}
prev = _prev_usage.setdefault(
id(transport), {"calls": 0, "prompt": 0, "output": 0, "duration_ns": 0}
)
prev = _prev_usage.setdefault(id(transport), {"calls": 0, "prompt": 0, "output": 0, "duration_ns": 0})
for key, value in cur.items():
if isinstance(value, int) and not isinstance(value, bool):
run_usage[key] += max(0, value - prev[key])
@@ -1667,9 +1385,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
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")
)
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
@@ -1727,14 +1443,10 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
"active_objective": capsule.active_objective,
"blocks": [block.to_dict() for block in capsule.blocks],
}
response.pending_state["exposed_tools"] = _sorted_tool_names(
getattr(runtime, "tool_names", ())
)
response.pending_state["exposed_tools"] = _sorted_tool_names(getattr(runtime, "tool_names", ()))
terminal_refusal = (
response.success
and response.pending_approval is None
and override.is_refusal(response.user_text)
response.success and response.pending_approval is None and override.is_refusal(response.user_text)
)
if terminal_refusal and current_tier < max_tier:
current_tier += 1
@@ -1775,16 +1487,12 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
runtime_objects: list = []
runtime_objects.append(_memory_query_from_trace(trace, task_id).to_dict())
for compaction_event in trace.get("compaction", []):
runtime_objects.append(
_observation_from_compaction(compaction_event, task_id).to_dict()
)
runtime_objects.append(_observation_from_compaction(compaction_event, task_id).to_dict())
response.runtime_objects = runtime_objects
# Shared emission (verification/outcome/degraded) — same
# helper the approval-resume path uses (quality I-2).
self._attach_runtime_objects(response, task_id)
response.runtime_objects = (
runtime_objects + list(response.runtime_objects)
)
response.runtime_objects = runtime_objects + list(response.runtime_objects)
except Exception as exc:
try:
from .subsystem_health import record_failure
@@ -1890,9 +1598,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# 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_messages=[dict(m) for m in _sent if isinstance(m, dict)],
capsule_content_hashes=capsule_hashes,
capsule_token_count=(
int(capsule.token_count)
@@ -1900,6 +1606,12 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
and not isinstance(getattr(capsule, "token_count", 0), bool)
else 0
),
# P1-03 (#26) schema v5: memory-selection fields —
# session-carried summary of the ONE selector's run.
memory_candidates=int(session.get("memory_candidates", 0) or 0),
memory_selected=int(session.get("memory_selected", 0) or 0),
memory_abstained=bool(session.get("memory_abstained", trace["memory_tokens"] == 0)),
memory_types_queried=list(session.get("memory_types_queried", []) or []),
)
record_trace(trace_record)
# P1-06 (quality I-2 discharge): CLOSE the measured loop —
@@ -1911,9 +1623,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
except ImportError: # TUI top-level deploy (#168)
from rune.model_routing import update_profiles_from_traces
merged = update_profiles_from_traces(
self._routing_profiles_path(), [asdict(trace_record)]
)
merged = update_profiles_from_traces(self._routing_profiles_path(), [asdict(trace_record)])
from .model_routing import save_profiles as _save_profiles
_save_profiles(self._routing_profiles_path(), merged)
@@ -1940,7 +1650,9 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Unreachable in practice (the tier loop always returns or raises),
# but the declared contract requires a value on all code paths.
return OperatorResponse(success=False, model=model, error="task execution ended without a result", degraded=sorted(set(degraded)))
return OperatorResponse(
success=False, model=model, error="task execution ended without a result", degraded=sorted(set(degraded))
)
@staticmethod
def _finalize_runtime_response(
@@ -1956,9 +1668,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
response.model = response.model or model
response.override_tier = override_tier
response.recall_count = recall_count
has_tool_failure = any(
event.kind in {"error", "failed"} for event in response.tool_events
)
has_tool_failure = any(event.kind in {"error", "failed"} for event in response.tool_events)
valid_final = (
response.success
and response.pending_approval is None
@@ -1980,10 +1690,7 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Rotate if file exceeds 10MB
try:
if (
os.path.exists(log_path)
and os.path.getsize(log_path) > 10 * 1024 * 1024
):
if os.path.exists(log_path) and os.path.getsize(log_path) > 10 * 1024 * 1024:
rotated = log_path + ".old"
os.replace(log_path, rotated)
except OSError:
+7 -1
View File
@@ -743,7 +743,13 @@ def main():
"--topic", ev.get("target", "general"),
"--importance", str(min(0.95, 0.4 + 0.1 * int(ev.get("strength", 1) or 1))),
"--source", ev.get("source", "curator"),
"--method", ev.get("source", "curator")],
"--method", ev.get("source", "curator"),
# P1-03 (#26) discharge (finding 2b): growth
# evidence targets persona/identity growth —
# the writer genuinely knows the type, so
# classify at write time.
"--memory-type",
"personality" if ev.get("target") == "persona" else "semantic"],
capture_output=True, text=True, timeout=30)
if '"added": True' in (r.stdout or ""):
added += 1
+5 -5
View File
@@ -277,20 +277,20 @@ def write_neuron(slug, triggers, body, importance=0.5):
"AND slug=?", (slug,)).fetchone()
if existing:
db.execute("UPDATE entries SET text=?, triggers=?, importance=?, "
"last_accessed=?, summary=? WHERE id=?",
"last_accessed=?, summary=?, memory_type=? WHERE id=?",
((body or "").strip(), ", ".join(triggers),
float(importance), ts, _aaak(body), existing[0]))
float(importance), ts, _aaak(body), "associative", existing[0]))
eid = existing[0]
else:
cur = db.execute(
"INSERT INTO entries (text, importance, created_at, "
"last_accessed, topic, source, method, status, valid_from, "
"confidence, temperature, always_on, priority, triggers, "
"kind, slug, summary) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"kind, slug, summary, memory_type) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
((body or "").strip(), float(importance), ts, ts, "warm",
"warm_router", "warm_router", "active", ts, 0.9, 1.0, 0, 5,
", ".join(triggers), "neuron", slug, _aaak(body)))
", ".join(triggers), "neuron", slug, _aaak(body), "associative"))
eid = cur.lastrowid
db.commit()
finally:
-189
View File
@@ -1,189 +0,0 @@
"""Unit tests for the CI dependency-lock drift checker (#150).
The diff/exit logic is exercised against fixture files in temp directories —
no network access and no real `uv pip compile` invocation here (the compile is
monkeypatched). The real end-to-end check runs in CI's lint job.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import check_dep_lock # noqa: E402
def write(tmp_path: Path, name: str, text: str) -> Path:
path = tmp_path / name
path.write_text(text)
return path
COMMITTED = """\
# CI dependency lock (issue #150) — hand-maintained header.
aiohttp==3.14.3
# via rune (pyproject.toml)
textual==8.2.8
"""
def test_uv_binary_prefers_interpreter_adjacent_uv(tmp_path, monkeypatch):
# #150 gate review: the checker must resolve the uv installed NEXT TO the
# interpreter running it (CI pins uv into .ci-venv/bin), not a stray uv
# from PATH with a different resolver version.
bin_dir = tmp_path / "venv" / "bin"
bin_dir.mkdir(parents=True)
fake_uv = bin_dir / "uv"
fake_uv.write_text("#!/bin/sh\n")
monkeypatch.setattr(sys, "executable", str(bin_dir / "python"))
monkeypatch.setenv("PATH", "") # nothing findable via PATH
monkeypatch.setenv("HOME", str(tmp_path)) # and no ~/.local/bin fallback
assert check_dep_lock.uv_binary() == str(fake_uv)
def test_uv_binary_prefers_interpreter_adjacent_over_path_uv(tmp_path, monkeypatch):
# The actual CI defect: PATH carried /usr/bin/uv (0.12.10) and won over
# the pinned 0.10.11 in .ci-venv/bin. Interpreter-adjacent must win.
bin_dir = tmp_path / "venv" / "bin"
bin_dir.mkdir(parents=True)
adjacent_uv = bin_dir / "uv"
adjacent_uv.write_text("#!/bin/sh\n")
decoy_dir = tmp_path / "decoy"
decoy_dir.mkdir()
(decoy_dir / "uv").write_text("#!/bin/sh\n")
monkeypatch.setattr(sys, "executable", str(bin_dir / "python"))
monkeypatch.setenv("PATH", str(decoy_dir))
monkeypatch.setenv("HOME", str(tmp_path))
assert check_dep_lock.uv_binary() == str(adjacent_uv)
def test_lock_requirements_extracts_pins_and_strips_annotations(tmp_path):
path = write(tmp_path, "reqs.txt", COMMITTED)
assert check_dep_lock.lock_requirements(path) == {"aiohttp": "3.14.3", "textual": "8.2.8"}
def test_lock_requirements_normalizes_names(tmp_path):
path = write(tmp_path, "reqs.txt", "typing_extensions==4.16.0\nPyYAML==6.0.2\n")
pins = check_dep_lock.lock_requirements(path)
assert pins == {"typing-extensions": "4.16.0", "pyyaml": "6.0.2"}
def test_lock_requirements_skips_options_and_rejects_floor_specs(tmp_path, capsys):
path = write(tmp_path, "reqs.txt", "-r base.txt\n--index-url https://example\ntextual==8.2.8\n")
assert check_dep_lock.lock_requirements(path) == {"textual": "8.2.8"}
# The 'rune>=0.49.0' floor that used to be skipped here is now covered by
# test_lock_requirements_rejects_floor_specs (it must exit 1).
def test_lock_requirements_rejects_floor_specs(tmp_path, capsys):
# #150 gate review: a degraded floor-style lock (e.g. 'rune>=0.49.0')
# must fail loudly, not silently pass the pin diff as a skip.
path = write(tmp_path, "reqs.txt", "textual==8.2.8\nrune>=0.49.0\n")
try:
check_dep_lock.lock_requirements(path)
except SystemExit as exc:
assert exc.code == 1
err = capsys.readouterr().err
assert "rune>=0.49.0" in err
assert "requirements-ci.txt" in err
else:
raise AssertionError("floor-style lock line must exit 1, not be skipped")
def test_lock_requirements_rejects_unpinned_editable_and_wildcard(tmp_path, capsys):
# Other non-pinned requirement forms must trip the same ratchet.
for bad in ("rune\n", "textual~=8.2\n", "aiohttp==3.*\n"):
path = write(tmp_path, "reqs.txt", bad)
try:
check_dep_lock.lock_requirements(path)
except SystemExit as exc:
assert exc.code == 1
else:
raise AssertionError(f"non-pinned line must exit 1: {bad!r}")
def test_compare_identical_pins_with_different_comments_is_in_sync(tmp_path):
committed = write(tmp_path, "committed.txt", COMMITTED)
regenerated = write(
tmp_path,
"regen.txt",
"# autogenerated by uv via command with a temp -o path\naiohttp==3.14.3\n # via rune (pyproject.toml)\ntextual==8.2.8\n",
)
assert check_dep_lock.compare(committed, regenerated) is None
def test_compare_missing_pin_is_reported(tmp_path):
committed = write(tmp_path, "committed.txt", "aiohttp==3.14.3\ntextual==8.2.8\n")
regenerated = write(tmp_path, "regen.txt", "aiohttp==3.14.3\n")
report = check_dep_lock.compare(committed, regenerated)
assert report is not None
assert "textual" in report
def test_compare_changed_pin_is_reported(tmp_path):
committed = write(tmp_path, "committed.txt", "aiohttp==3.14.3\n")
regenerated = write(tmp_path, "regen.txt", "aiohttp==3.15.0\n")
report = check_dep_lock.compare(committed, regenerated)
assert report is not None
assert "3.14.3" in report and "3.15.0" in report
def test_compare_report_carries_regen_hint(tmp_path):
committed = write(tmp_path, "committed.txt", "aiohttp==3.14.3\n")
regenerated = write(tmp_path, "regen.txt", "aiohttp==3.15.0\n")
report = check_dep_lock.compare(committed, regenerated)
assert report is not None
assert "uv pip compile" in report
assert "--python-version 3.11" in report
def test_main_exit_zero_when_in_sync(tmp_path, monkeypatch, capsys):
committed = write(tmp_path, "requirements-ci.txt", COMMITTED)
monkeypatch.setattr(check_dep_lock, "LOCK_FILE", committed)
def fake_compile(out_path: Path, root: Path = ROOT) -> int:
out_path.write_text(COMMITTED)
return 0
monkeypatch.setattr(check_dep_lock, "run_uv_compile", fake_compile)
assert check_dep_lock.main() == 0
assert "in sync" in capsys.readouterr().out
def test_main_exit_one_when_drifted(tmp_path, monkeypatch, capsys):
committed = write(tmp_path, "requirements-ci.txt", COMMITTED)
monkeypatch.setattr(check_dep_lock, "LOCK_FILE", committed)
def fake_compile(out_path: Path, root: Path = ROOT) -> int:
out_path.write_text("aiohttp==3.15.0\ntextual==8.2.8\n")
return 0
monkeypatch.setattr(check_dep_lock, "run_uv_compile", fake_compile)
assert check_dep_lock.main() == 1
err = capsys.readouterr().err
assert "uv pip compile" in err
assert "aiohttp" in err
def test_main_missing_uv_is_actionable_not_traceback(tmp_path, monkeypatch, capsys):
# #150 gate review: with uv absent (PATH stripped, no interpreter-adjacent
# or ~/.local/bin fallback), the checker must print the house actionable
# pattern — what to install, what command — not a raw FileNotFoundError.
committed = write(tmp_path, "requirements-ci.txt", COMMITTED)
monkeypatch.setattr(check_dep_lock, "LOCK_FILE", committed)
monkeypatch.setenv("PATH", "")
monkeypatch.setattr(check_dep_lock, "uv_binary", lambda: "no-such-uv-binary-xyz")
assert check_dep_lock.main() == 1
err = capsys.readouterr().err
assert "uv" in err and "not found" in err
assert "pip install" in err # what to install / what command
assert "Traceback" not in err
def test_main_exit_one_when_compile_fails(tmp_path, monkeypatch, capsys):
committed = write(tmp_path, "requirements-ci.txt", COMMITTED)
monkeypatch.setattr(check_dep_lock, "LOCK_FILE", committed)
monkeypatch.setattr(check_dep_lock, "run_uv_compile", lambda out_path, root=ROOT: 2)
assert check_dep_lock.main() == 1
assert "failed" in capsys.readouterr().err
+10 -3
View File
@@ -399,9 +399,16 @@ def test_operator_characterization_parity():
patch.object(rune_operator.model_profiles, "get_prompt_context", return_value="model context"),
patch.object(rune_operator.model_profiles, "record_interaction") as record,
patch("rune.memory_store.connect", return_value=memory_db),
patch(
"rune.memory_store.cached_recall",
return_value=[{"text": "remembered fact"}],
# P1-03 (#26): memory selection authority moved to the selector —
# script the store SOURCE there (same injected memory, same count).
patch.dict(
"rune.memory_selector.DEFAULT_SOURCES",
{
"store": lambda query, types, budget, min_score: [
{"text": "remembered fact", "score": 0.9, "kind": "store"}
]
},
clear=True,
),
patch.object(operator, "_log_token_usage"),
):
-37
View File
@@ -153,40 +153,3 @@ def test_main_exits_0_all_merged(forge, monkeypatch):
monkeypatch.setattr(mr, "scan", fake_scan)
assert mr.main_with(["merge_robot.py", "tok", "base"]) == 0
def test_verify_identity_uses_instance_base_not_repo_scoped(forge, monkeypatch):
"""RED (#214 follow-up): verify_identity received the REPO-scoped base and
built .../repos/o/r/user — a 404 with a plain-text body that crashed
json.loads before MergeRefused could fire. Identity must be verified at the
INSTANCE root: .../api/v1/user."""
recorded = []
def spy_api(base, token, path, method="GET", data=None, ok=(200,)):
recorded.append((base, path))
return 200, {"login": "conductor"}
monkeypatch.setattr(mr, "_api", spy_api)
mr.verify_identity("http://x/api/v1/repos/o/r", "tok")
assert recorded == [("http://x/api/v1", "/user")], (
"identity check must hit the instance root /user, not the repo-scoped path"
)
def test_api_handles_non_json_error_body(monkeypatch):
# NOTE: no forge fixture — it replaces _api entirely, which would make the
# urlopen patch unreachable. This test exercises the REAL _api.
"""A 404 plain-text body ('404 page not found') must raise MergeRefused,
not an unhandled JSONDecodeError."""
import urllib.error
class FakeHTTPError(urllib.error.HTTPError):
def __init__(self):
super().__init__("url", 404, "not found", hdrs=None, fp=None)
def real_url(req, timeout):
raise FakeHTTPError()
monkeypatch.setattr(mr.urllib.request, "urlopen", real_url)
with pytest.raises(mr.MergeRefused):
mr._api("http://x/api/v1", "tok", "/user")
+5 -5
View File
@@ -326,9 +326,9 @@ def test_default_runtime_still_exposes_full_tools():
def test_inference_trace_schema_v3_fields():
from rune import inference_trace as it
# 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
# P1-02 (#25) bumped the schema to v4, P1-03 (#26) to v5 (additive);
# assert against the module constant, not a pinned literal.
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 5
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
@@ -422,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"] == 4
assert record["schema_version"] == it.INFERENCE_TRACE_SCHEMA_VERSION
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
@@ -498,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"] == 4
assert record["schema_version"] == it.INFERENCE_TRACE_SCHEMA_VERSION
assert record["selected_tools"] == tool_names
assert record["capability_reason"]
assert record["find_capability_calls"] == 0
+3 -1
View File
@@ -379,7 +379,9 @@ def test_inference_trace_schema_v4_fields():
capsule_token_count with defaults; v1-v3 readers unaffected."""
from rune import inference_trace as it
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
# P1-03 (#26) bumped the schema to v5 (additive memory-selection fields);
# assert against the module constant, not a pinned literal.
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 5
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
File diff suppressed because it is too large. Load diff
-164
View File
@@ -1,164 +0,0 @@
"""Red-first tests for scripts/research_backfill_sweep.py.
Nick's critique, structuralized (2026-09-07):
- "is research being done or are these issues with research already done?"
→ the sweep must SKIP issues that already carry a 4-phase research round
(comment with INWARD/OUTWARD/INNOVATION/SYNTHESIS markers) — relabeling
them would re-dispatch research for work already done.
- The sweep must mirror the gate's OWN readiness semantics (pipeline-gate.yml
teach job): open, not blocked, all `Depends on: #N` closed (verified against
the server — a missing dep is UNSATISFIED, never assumed closed).
- Idempotent: issues already carrying research-pending are skipped.
- Dry-run by default; --apply is the explicit mutation.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location(
"research_backfill_sweep", ROOT / "scripts" / "research_backfill_sweep.py"
)
rs = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(rs)
def _issue(n, title="work item", labels=(), body="", closed=False, is_pr=False):
d = {
"number": n,
"title": title,
"labels": [{"name": l} for l in labels],
"body": body,
"state": "closed" if closed else "open",
}
if is_pr:
d["pull_request"] = {"url": "x"}
return d
RESEARCH_COMMENT = "## INWARD\n... ## OUTWARD\n... ## INNOVATION\n... ## SYNTHESIS\n..."
class FakeForge:
"""Simulates the SERVER contract: issues list + per-issue fetch + comments."""
def __init__(self, issues, comments=None):
self.issues = issues
self.comments = comments or {} # issue_number -> [comment bodies]
self.added_labels: list[tuple[int, str]] = []
def get(self, path):
if path == "/issues?state=open&type=issues&limit=50&page=1":
return 200, [i for i in self.issues if not i.get("pull_request")]
if path.startswith("/issues/") and path.endswith("/comments"):
n = int(path.split("/")[2])
return 200, self.comments.get(n, [])
if path.startswith("/issues/"):
n = int(path.split("/")[2])
for i in self.issues:
if i["number"] == n:
return 200, i
return 404, {}
raise AssertionError(f"unexpected GET {path}")
def post(self, path, data):
if path.startswith("/issues/") and path.endswith("/labels"):
n = int(path.split("/")[2])
for label in data.get("labels", []):
self.added_labels.append((n, label))
return 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,)):
if method == "GET":
status, body = f.get(path)
else:
status, body = f.post(path, data)
if isinstance(body, str): # non-JSON error body passthrough
raise rs.SweepError(f"{method} {path} -> {status}: {body[:120]}")
return status, body
monkeypatch.setattr(rs, "_api", fake_api)
return f
def test_skips_issue_with_existing_research_round(forge):
"""Already-researched issues are NOT relabeled (Nick: 'already done?')."""
forge.issues = [_issue(10, labels=["research-pending"])]
forge.comments = {10: [RESEARCH_COMMENT]}
targets = rs.compute_targets(forge.issues, forge)
assert targets == []
def test_labels_ready_issue_without_research(forge):
forge.issues = [_issue(11)]
targets = rs.compute_targets(forge.issues, forge)
assert [t["number"] for t in targets] == [11]
def test_skips_blocked_issues(forge):
forge.issues = [_issue(12, labels=["blocked"])]
assert rs.compute_targets(forge.issues, forge) == []
def test_skips_issues_with_open_dependency(forge):
"""Gate semantics: an open dep blocks; a MISSING dep is unsatisfied too."""
forge.issues = [
_issue(13, body="Depends on: #99"),
_issue(14, body="Depends on: #404-missing"),
]
forge.issues[1]["body"] = "Depends on: #777"
targets = rs.compute_targets(forge.issues, forge)
assert targets == []
def test_satisfied_dependency_allows_labeling(forge):
forge.issues = [_issue(15, body="Depends on: #16"), _issue(16, closed=True)]
targets = rs.compute_targets(forge.issues, forge)
assert [t["number"] for t in targets] == [15]
def test_skips_already_labeled(forge):
forge.issues = [_issue(17, labels=["research-pending"])]
assert rs.compute_targets(forge.issues, forge) == []
def test_skips_prs_and_closed(forge):
forge.issues = [_issue(18, is_pr=True), _issue(19, closed=True)]
assert rs.compute_targets(forge.issues, forge) == []
def test_apply_posts_labels(forge):
forge.issues = [_issue(20), _issue(21)]
applied = rs.apply(forge, [t["number"] for t in rs.compute_targets(forge.issues, forge)])
assert sorted(applied) == [20, 21]
assert sorted(forge.added_labels) == [(20, "research-pending"), (21, "research-pending")]
def test_main_dry_run_default_makes_no_writes(forge, monkeypatch, capsys):
forge.issues = [_issue(22)]
monkeypatch.setattr(rs.ForgeAdapter, "get", lambda self, path: forge.get(path))
monkeypatch.setattr(rs.ForgeAdapter, "post", lambda self, path, data: forge.post(path, data))
rc = rs.main_with(["research_backfill_sweep.py", "sweep", "--base", "http://x/api/v1/repos/o/r", "--token", "t"])
assert rc == 0
assert forge.added_labels == [], "dry-run (default) must not mutate"
def test_main_apply_writes(forge, monkeypatch, capsys):
forge.issues = [_issue(23)]
monkeypatch.setattr(rs.ForgeAdapter, "get", lambda self, path: forge.get(path))
monkeypatch.setattr(rs.ForgeAdapter, "post", lambda self, path, data: forge.post(path, data))
rc = rs.main_with(
["research_backfill_sweep.py", "sweep", "--base", "http://x/api/v1/repos/o/r", "--token", "t", "--apply"]
)
assert rc == 0
assert forge.added_labels == [(23, "research-pending")]
+27 -57
View File
@@ -149,9 +149,7 @@ class RuneOperatorToolTests(unittest.TestCase):
request.source,
)
self.operator.state.tasks.append(
rune_operator.RuneTask(
"task-1", "run tests", "running", "qwen3:8b", created_at="now"
)
rune_operator.RuneTask("task-1", "run tests", "running", "qwen3:8b", created_at="now")
)
self.operator.state.current_task = "task-1"
self.operator.state.pending_task_id = "task-1"
@@ -164,9 +162,7 @@ class RuneOperatorToolTests(unittest.TestCase):
self.operator.state.pending_runtime_state = {"messages": []}
self.operator._save()
runtime = Mock()
runtime.approve.return_value = OperatorResponse(
success=False, model="qwen3:8b", error="terminal failure"
)
runtime.approve.return_value = OperatorResponse(success=False, model="qwen3:8b", error="terminal failure")
with patch.object(self.operator, "_runtime_for_model", return_value=runtime):
result = self.operator.resolve_pending_approval(True)
@@ -179,18 +175,14 @@ class RuneOperatorToolTests(unittest.TestCase):
def test_approved_write_never_bypasses_prepared_runtime_authority(self):
request = self.request()
self.operator.state.tasks.append(
rune_operator.RuneTask(
"task-1", "write file", "running", "qwen3:8b", created_at="now"
)
rune_operator.RuneTask("task-1", "write file", "running", "qwen3:8b", created_at="now")
)
self.operator.state.current_task = "task-1"
self.operator.state.pending_task_id = "task-1"
self.operator.state.pending_request = asdict(request)
self.operator.state.pending_runtime_state = {"messages": []}
runtime = Mock()
runtime.approve.return_value = OperatorResponse(
success=True, user_text="Written.", model="qwen3:8b"
)
runtime.approve.return_value = OperatorResponse(success=True, user_text="Written.", model="qwen3:8b")
with (
patch.object(self.operator, "_runtime_for_model", return_value=runtime),
@@ -202,15 +194,11 @@ class RuneOperatorToolTests(unittest.TestCase):
self.assertTrue(result["success"])
def test_failed_runtime_preserves_failure_envelope_and_persists_failed_task(self):
request = ToolRequest(
"call-2", "read_file", {"path": "/tmp/a"}, "ollama-native"
)
request = ToolRequest("call-2", "read_file", {"path": "/tmp/a"}, "ollama-native")
response = OperatorResponse(
success=False,
thinking=["The response was malformed."],
tool_events=[
ToolEvent("error", request, {"success": False, "error": "bad"})
],
tool_events=[ToolEvent("error", request, {"success": False, "error": "bad"})],
model="qwen3:8b",
recall_count=1,
override_tier=1,
@@ -235,9 +223,7 @@ class RuneOperatorToolTests(unittest.TestCase):
self.operator.state.pending_task_id = "stale-task"
self.operator.state.pending_request = {"name": "write_file"}
self.operator.state.pending_runtime_state = {"messages": []}
response = OperatorResponse(
success=True, user_text="Finished.", model="qwen3:8b"
)
response = OperatorResponse(success=True, user_text="Finished.", model="qwen3:8b")
with patch.object(self.operator, "_execute_on_model", return_value=response):
self.operator.send_task("new task", "qwen3:8b")
@@ -249,9 +235,7 @@ class RuneOperatorToolTests(unittest.TestCase):
def test_failed_tool_events_override_success_and_persist_structured_failure(self):
for index, event_kind in enumerate(("error", "failed"), start=3):
request = ToolRequest(
f"call-{index}", "read_file", {"path": "/tmp/a"}, "ollama-native"
)
request = ToolRequest(f"call-{index}", "read_file", {"path": "/tmp/a"}, "ollama-native")
response = OperatorResponse(
success=True,
user_text="I finished successfully.",
@@ -278,9 +262,7 @@ class RuneOperatorToolTests(unittest.TestCase):
self.assertNotEqual(result["task"]["result"], response.user_text)
persisted = json.loads(self.storage.read_text())
self.assertEqual(
[task["status"] for task in persisted["tasks"]], ["failed", "failed"]
)
self.assertEqual([task["status"] for task in persisted["tasks"]], ["failed", "failed"])
self.assertEqual(
[task["result"] for task in persisted["tasks"]],
["permission denied", "permission denied"],
@@ -288,19 +270,13 @@ class RuneOperatorToolTests(unittest.TestCase):
def test_valid_local_final_uses_runtime_with_context_and_records_success(self):
runtime = Mock()
runtime.run.return_value = OperatorResponse(
success=True, user_text="Final answer", model="qwen3:8b"
)
runtime.run.return_value = OperatorResponse(success=True, user_text="Final answer", model="qwen3:8b")
profile = SimpleNamespace(safety_tier=2)
memory_db = Mock()
with (
patch.object(
rune_operator, "LocalToolRuntime", return_value=runtime, create=True
) as runtime_type,
patch.object(
rune_operator, "OllamaChatTransport", return_value=Mock(), create=True
),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime, create=True) as runtime_type,
patch.object(rune_operator, "OllamaChatTransport", return_value=Mock(), create=True),
patch.object(
rune_operator.model_profiles,
"get_or_create_profile",
@@ -317,16 +293,20 @@ class RuneOperatorToolTests(unittest.TestCase):
return_value=SimpleNamespace(token_count=10),
),
patch("rune.memory_store.connect", return_value=memory_db),
patch(
"rune.memory_store.cached_recall",
return_value=[{"text": "remembered fact"}],
# P1-03 (#26): the memory seam is now the selector's store source.
patch.dict(
"rune.memory_selector.DEFAULT_SOURCES",
{
"store": lambda query, types, budget, min_score: [
{"text": "remembered fact", "score": 0.9, "kind": "store"}
]
},
clear=True,
),
patch.object(self.operator, "_log_token_usage"),
):
response = self.operator._execute_on_model(
rune_operator.RuneTask(
"task-1", "inspect this", assigned_model="qwen3:8b"
),
rune_operator.RuneTask("task-1", "inspect this", assigned_model="qwen3:8b"),
"qwen3:8b",
)
@@ -350,9 +330,7 @@ class RuneOperatorToolTests(unittest.TestCase):
def test_terminal_failures_record_model_failure_while_pending_is_not_counted(self):
request = self.request()
cases = (
OperatorResponse(
success=False, model="qwen3:8b", error="iteration limit reached"
),
OperatorResponse(success=False, model="qwen3:8b", error="iteration limit reached"),
OperatorResponse(
success=True,
model="qwen3:8b",
@@ -363,9 +341,7 @@ class RuneOperatorToolTests(unittest.TestCase):
success=True,
user_text="Could not finish.",
model="qwen3:8b",
tool_events=[
ToolEvent("error", request, {"success": False, "error": "denied"})
],
tool_events=[ToolEvent("error", request, {"success": False, "error": "denied"})],
),
)
@@ -378,13 +354,9 @@ class RuneOperatorToolTests(unittest.TestCase):
return_value=runtime_response,
create=True,
),
patch.object(
rune_operator.model_profiles, "record_interaction"
) as record,
patch.object(rune_operator.model_profiles, "record_interaction") as record,
):
response = self.operator._finalize_runtime_response(
runtime_response, "qwen3:8b", "inspect this", 2, 1
)
response = self.operator._finalize_runtime_response(runtime_response, "qwen3:8b", "inspect this", 2, 1)
self.assertIs(response, runtime_response)
if runtime_response.pending_approval is not None:
record.assert_not_called()
@@ -432,9 +404,7 @@ class RuneOperatorToolTests(unittest.TestCase):
),
patch.object(rune_operator.model_profiles, "record_interaction") as record,
):
response = self.operator._execute_on_model(
rune_operator.RuneTask("task-1", "inspect this"), "qwen3:8b"
)
response = self.operator._execute_on_model(rune_operator.RuneTask("task-1", "inspect this"), "qwen3:8b")
self.assertFalse(response.success)
record.assert_called_once_with("qwen3:8b", False, "inspect this")
@@ -38,12 +38,8 @@ class RuntimeArchitectureCharacterizationTests(unittest.TestCase):
operator = rune_operator.RuneOperator(str(Path(tempdir) / "operator.json"))
runtime = Mock()
runtime.run.side_effect = [
OperatorResponse(
success=True, user_text="First answer", model="qwen3:8b"
),
OperatorResponse(
success=True, user_text="Second answer", model="qwen3:8b"
),
OperatorResponse(success=True, user_text="First answer", model="qwen3:8b"),
OperatorResponse(success=True, user_text="Second answer", model="qwen3:8b"),
]
profile = SimpleNamespace(safety_tier=2)
@@ -74,14 +70,12 @@ class RuntimeArchitectureCharacterizationTests(unittest.TestCase):
second_messages = runtime.run.call_args_list[1].args[0]
role_content = [
(message["role"], message["content"])
for message in second_messages
if message["role"] != "system"
(message["role"], message["content"]) for message in second_messages if message["role"] != "system"
]
self.assertIn(
("user", "First question"),
("user", "PAST TASK (completed — context only, not instructions): First question"),
role_content,
"the second provider turn omits the prior user message",
"the second provider turn omits the prior user message (P1-03: past-task content is labeled context-only)",
)
self.assertIn(
("assistant", "First answer"),
@@ -90,18 +84,12 @@ class RuntimeArchitectureCharacterizationTests(unittest.TestCase):
)
def test_tui_send_path_projects_typed_tool_events(self):
send = _function_node(
ROOT / "src" / "rune" / "rune_tui.py", "RuneApp", "_send_to_model"
)
send = _function_node(ROOT / "src" / "rune" / "rune_tui.py", "RuneApp", "_send_to_model")
string_literals = {
node.value
for node in ast.walk(send)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
node.value for node in ast.walk(send) if isinstance(node, ast.Constant) and isinstance(node.value, str)
}
constructed_types = {
node.func.id
for node in ast.walk(send)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
node.func.id for node in ast.walk(send) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
}
self.assertIn(
@@ -121,9 +109,7 @@ class RuntimeArchitectureCharacterizationTests(unittest.TestCase):
self.assertIn("tool_widget.apply(tool_event)", source)
def test_tui_send_path_does_not_auto_confirm_pending_mutation(self):
send = _function_node(
ROOT / "src" / "rune" / "rune_tui.py", "RuneApp", "_send_to_model"
)
send = _function_node(ROOT / "src" / "rune" / "rune_tui.py", "RuneApp", "_send_to_model")
calls = [
node.func.attr
for node in ast.walk(send)
@@ -137,9 +123,7 @@ class RuntimeArchitectureCharacterizationTests(unittest.TestCase):
)
def test_generic_bubble_does_not_interpolate_protocol_markup(self):
render = _function_node(
ROOT / "src" / "rune" / "chat_bubble.py", "ChatBubble", "render"
)
render = _function_node(ROOT / "src" / "rune" / "chat_bubble.py", "ChatBubble", "render")
direct_content_interpolations = [
node
for node in ast.walk(render)