Compare commits

..
Author SHA1 Message Date
Nick 08b0037ed4 style: ruff format sweep script (lint gate)
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 27s
CI Pipeline / unit-tests (pull_request) Successful in 33s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / type-check (pull_request) Successful in 22s
Spec gate (headless) / spec-gate (pull_request) Successful in 7s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / lint (push) Failing after 35s
CI Pipeline / unit-tests (push) Failing after 45s
CI Pipeline / canary (push) Failing after 46s
CI Pipeline / type-check (push) Successful in 35s
CI Pipeline / required-checks (push) Skipped
2026-09-07 23:08:53 +01:00
Nick db14f66bc0 feat(pipeline): research structuralization — dispatched headless research stage + idempotent backlog sweep (#204 second slice)
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Failing after 20s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / type-check (push) Successful in 25s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / unit-tests (push) Successful in 36s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Failing after 20s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / canary (pull_request) Successful in 28s
CI Pipeline / unit-tests (pull_request) Successful in 33s
CI Pipeline / type-check (pull_request) Successful in 23s
CI Pipeline / required-checks (pull_request) Skipped
Spec gate (headless) / spec-gate (pull_request) Successful in 8s
Nick's critique structuralized (2026-09-07):
- 'is research being done or already done?' → sweep detects existing
  4-phase rounds (INWARD/OUTWARD/INNOVATION/SYNTHESIS comments) and SKIPS
  those issues; never re-researches completed work
- research must CALL the LLM → research-stage-headless.yml dispatches headless
  mimo on research-pending label events (spec-gate runner pattern, verified)
- filed as a research FILE per issue → research/issue-<n>-research.md merged
  to master via auto-PR; comment carries the markers research-clear keys on
- 4 phases → SYNTHESIS ends in ordered build instructions = implementer's prompt
- TUI callback seam → prompt externalized to governed prompts/research-round.md
  (placeholders substituted at runtime; L3/TUI owns it via reviewed diffs later)
- failsafe: sweep dry-run by default; --apply explicit; red-first tests (10)

Also: spec-gate-headless.yml copied to master stream with ROBOT_TOKEN wired
(same reserved-secret bug the robot had — GITEA_TOKEN is the read-only auto token).
2026-09-07 23:05:17 +01:00
conductor c36e5b07ac Merge pull request 'Reproducible CI dependency lock + drift ratchet (closes #150)' (#205) from ci-dependency-lock into master
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / lint (push) Successful in 26s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / unit-tests (push) Successful in 34s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 15s
CI Pipeline / required-checks (push) Skipped
2026-09-07 22:51:55 +01:00
Nick 79ea4bcf95 merge master (behind-base update, round 2)
CI Pipeline / lint (push) Successful in 29s
CI Pipeline / canary (push) Successful in 38s
CI Pipeline / unit-tests (push) Successful in 45s
CI Pipeline / gitea-offline (push) Successful in 15s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / lint (pull_request) Successful in 28s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 34s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / type-check (pull_request) Successful in 22s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 22:45:34 +01:00
Nick 73f3c15da4 merge master (behind-base update for robot drain)
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / lint (push) Successful in 30s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / unit-tests (push) Successful in 34s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / lint (pull_request) Successful in 29s
CI Pipeline / unit-tests (pull_request) Successful in 34s
CI Pipeline / canary (pull_request) Successful in 28s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / type-check (pull_request) Successful in 22s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 22:34:12 +01:00
conductor fcf1851e23 Merge pull request 'Merge-robot identity: instance-root /user + non-JSON error body handling (red-first)' (#216) from merge-robot-checkout into master
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 27s
CI Pipeline / canary (push) Successful in 27s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 22:32:09 +01:00
Nick 34b200f800 merge master (behind-base)
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 25s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / documentation (pull_request) Successful in 4s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / unit-tests (pull_request) Successful in 39s
CI Pipeline / type-check (pull_request) Successful in 23s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 22:29:26 +01:00
Nick 61485e2fdc fix(pipeline): merge-robot identity check hits instance root /user, handles non-JSON error bodies
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 26s
CI Pipeline / canary (push) Successful in 29s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / best-practices (pull_request) Successful in 4s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 27s
CI Pipeline / canary (pull_request) Successful in 30s
CI Pipeline / type-check (pull_request) Successful in 22s
CI Pipeline / unit-tests (pull_request) Successful in 35s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 1s
Red-first discharge (#214 follow-up): verify_identity built a repo-scoped URL
(.../repos/o/r/user -> 404 plain-text body) which crashed json.loads before
MergeRefused could fire — the robot never ran successfully since #211. Three
stacked defects: missing checkout (#214), reserved-secret auth (#213), and
repo-scoped identity URL (this commit).

- verify_identity now derives the instance root itself (defense in depth:
  never trust caller scope)
- _api converts non-JSON error bodies into MergeRefused instead of an
  unhandled JSONDecodeError
- 2 red tests added first, frozen green; suite 615+84
2026-09-07 22:22:42 +01:00
conductor 02f6765f3e Merge pull request 'Merge-robot YAML fix: quote checkout step name (unquoted colon made workflow unparseable)' (#215) from merge-robot-checkout into master
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 25s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 36s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-07 22:15:35 +01:00
Nick 78c7374b46 merge master into merge-robot-checkout (behind-base fix)
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 28s
CI Pipeline / canary (push) Successful in 32s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / unit-tests (push) Successful in 36s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 34s
CI Pipeline / type-check (pull_request) Successful in 22s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 22:12:21 +01:00
Nick be03e22a28 fix(pipeline): quote checkout step name — unquoted colon-space in YAML scalar made the whole workflow unparseable
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / best-practices (pull_request) Successful in 4s
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / lint (pull_request) Successful in 27s
CI Pipeline / canary (pull_request) Successful in 32s
CI Pipeline / type-check (pull_request) Successful in 22s
CI Pipeline / unit-tests (pull_request) Successful in 35s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 22:05:58 +01:00
conductor 6e033ef489 Merge pull request 'Merge-robot checkout: script file missing from job workspace (exit 2 since #211)' (#214) from merge-robot-checkout into master
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 25s
CI Pipeline / canary (push) Successful in 27s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-07 22:05:27 +01:00
Nick 2e5bb93d75 fix(pipeline): merge-robot checks out the repo — script file never existed in job workspace (exit 2 since #211; second stacked root cause beside reserved-secret auth)
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 26s
CI Pipeline / canary (push) Successful in 27s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / unit-tests (pull_request) Successful in 33s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 22:02:30 +01:00
conductor 9530413578 Merge pull request 'Merge-robot auth: ROBOT_TOKEN secret (GITEA_TOKEN is reserved read-only auto token)' (#213) from merge-robot-auth into master
CI Pipeline / best-practices (push) Successful in 5s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 28s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-07 21:59:21 +01:00
Nick c68f313229 fix(pipeline): merge-robot authenticates via ROBOT_TOKEN secret — GITEA_TOKEN is the reserved read-only auto token (root cause of 18 consecutive loud failures)
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 3s
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 27s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 36s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 4s
CI Pipeline / lint (pull_request) Successful in 42s
CI Pipeline / unit-tests (pull_request) Successful in 58s
CI Pipeline / gitea-offline (pull_request) Successful in 24s
CI Pipeline / type-check (pull_request) Successful in 51s
CI Pipeline / canary (pull_request) Successful in 1m22s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 21:53:49 +01:00
Nick 50eac65d18 Merge remote-tracking branch 'gitea/master' into ci-dependency-lock
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 34s
CI Pipeline / canary (pull_request) Successful in 23s
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 22s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / lint (push) Failing after 38s
CI Pipeline / canary (push) Failing after 41s
CI Pipeline / type-check (push) Successful in 35s
CI Pipeline / unit-tests (push) Failing after 49s
CI Pipeline / required-checks (push) Skipped
2026-09-07 21:01:11 +01:00
conductor de8c7a6e47 Merge pull request 'Merge-robot fail-loud discharge: identity check, refusals fail the job (bug-fix contract first customer)' (#211) from merge-robot-fix into master
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 36s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 43s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / required-checks (push) Skipped
2026-09-07 20:46:26 +01:00
Nick 7402a68095 fix(pipeline): merge-robot fails loudly on refusals, verifies token identity, extracted testable script — red-first discharge of the silent no-op (bug-fix contract) (#147)
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 31s
CI Pipeline / canary (pull_request) Successful in 30s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 18:14:10 +01:00
Nick c8c8d85624 Merge remote-tracking branch 'gitea/master' into ci-dependency-lock
CI Pipeline / lint (push) Successful in 47s
CI Pipeline / unit-tests (push) Successful in 36s
CI Pipeline / canary (push) Successful in 23s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 25s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 23s
CI Pipeline / best-practices (pull_request) Successful in 6s
CI Pipeline / documentation (pull_request) Successful in 7s
CI Pipeline / type-check (pull_request) Successful in 22s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 16:06:24 +01:00
Nick ada5456678 docs(ci): gate-review discharge record in plan completion record (#150)
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 1s
CI Pipeline / lint (push) Successful in 28s
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / canary (push) Successful in 41s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 25s
CI Pipeline / unit-tests (pull_request) Successful in 47s
CI Pipeline / canary (pull_request) Successful in 30s
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 22s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
Per-finding map: finding -> commit -> red evidence for all six gate
findings (F1-F6), final battery numbers, and the F3+F5 shared-commit
deviation note.
2026-09-07 15:11:22 +01:00
Nick 48d2cd8491 style(ci): ruff format check_dep_lock.py (#150)
Quote-style only (double -> single for the line containing \$PWD);
battery re-verified after format (15 checker tests pass).
2026-09-07 15:10:37 +01:00
Nick b24b6e3439 fix(ci): type-check installs from the pinned lock, not floating extras (#150)
Gate-review F6 (pre-existing): the type-check job still installed
".[dev,memory]" — the one remaining floating install, leaving the
PROTECTED type-check toolchain unreproducible. Switched to the same
lock-only pattern as lint/unit-tests/canary: pip install -r
requirements-ci.txt + pip install -e . --no-deps. The lock pins
pyright==1.1.411, so the ratchet now runs against a pinned pyright.

RED->GREEN: config-only finding; red evidence was ci.yml:196 installing
floating extras (gate finding). yaml.safe_load parses after the edit;
no floating 'pip install .[...] ' remains in any workflow.
2026-09-07 15:04:38 +01:00
Nick d3c70393dd docs(ci): disclose drift-checker limitations in module docstring (#150)
Gate-review F4: two known blind spots were undocumented — (1) marker-only
drift (env-marker changes with identical pins) is invisible to the pin
diff, (2) direct-URL requirement lines are skipped by the old parser /
now fail loudly instead of being compared. Both disclosed in the module
docstring with guidance; no behavior change.

RED->GREEN: docs-only finding; red evidence is the pre-existing docstring
lacking the disclosure (gate finding itself). Checker behavior and tests
unchanged (15 pass).
2026-09-07 15:02:38 +01:00
Nick 084778d884 fix(ci): actionable message when uv is missing; suppress compile stdout (#150)
Gate-review F3: with uv absent, run_uv_compile() let subprocess raise a
raw FileNotFoundError traceback. Now caught (sentinel -1) and main()
prints the house actionable pattern: what to install (pinned uv into
.ci-venv), what command (pip install + PATH export), local install doc.

Gate-review F5: uv's full resolution listing went to stdout even on
success, polluting CI logs. stdout/stderr are now captured and printed
only on compile failure.

RED->GREEN: new test (uv_binary stubbed to a missing binary, PATH
stripped) failed with FileNotFoundError, passes with exit 1 + actionable
stderr. Real smoke: successful run no longer prints uv's 'Resolved 37
packages' listing.
2026-09-07 14:58:58 +01:00
Nick b7aa770d0f fix(ci): lock checker fails loudly on non-pinned requirement lines (#150)
Gate-review F2: lock_requirements() silently skipped any requirement
line lacking '==', so a degraded floor-style lock (e.g. 'rune>=0.49.0')
would pass the drift diff with its floor pins intact. Options
(-r/--index-url/...) remain the only tolerated non-pin lines; any other
non-pinned form (floor, range, wildcard, bare name) now exits 1 with an
actionable message naming the offending line and the regen command.

RED->GREEN: two new tests (floor fixture + unpinned/wildcard variants)
failed against the skip behavior, pass after the loud-failure fix. The
pre-existing skip-options test was updated to match the tightened
contract; the committed lock (37 pins) still parses clean.
2026-09-07 14:55:19 +01:00
Nick 8568e57640 fix(ci): drift checker resolves the pinned uv, not a stray PATH uv (#150)
Gate-review F1: the drift-ratchet step installed uv==0.10.11 into
.ci-venv but never exported PATH, so check_dep_lock.py resolved
/usr/bin/uv (0.12.10) and re-compiled the lock with a different
resolver than generated it. Both ends fixed:

- ci.yml drift step: export PATH="$PWD/.ci-venv/bin:$PATH" before
  invoking the checker.
- check_dep_lock.uv_binary(): prefer the interpreter-adjacent uv
  (sys.executable's dir — where CI pins it) over shutil.which.

RED->GREEN: two new tests (PATH-stripped + decoy-uv-on-PATH) failed
against the old resolver order, pass after the fix.
2026-09-07 14:52:13 +01:00
Nick 1ce64e43fb docs(ci): dependency lock workflow (#150)
README Development section: how to regenerate requirements-ci.txt after
dependency edits (exact uv pip compile command), why --python-version 3.11
(runner-interpreter markers), and why CI installs the project with
--no-deps (deps only from the pinned lock). Regeneration command and
rationale also documented in the scripts/check_dep_lock.py header.
2026-09-07 14:13:52 +01:00
conductor 2975d7651d Merge pull request 'Merge robot: deterministic merge-on-green' (#203) from merge-robot into master
auto-version.yml (push) Invalid workflow file
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / lint (push) Successful in 37s
CI Pipeline / unit-tests (push) Failing after 36s
CI Pipeline / canary (push) Failing after 30s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / required-checks (push) Skipped
2026-09-07 14:12:35 +01:00
Nick c1dae4a7fa feat(ci): install from pinned lock + drift ratchet (#150)
All three CI venvs (.ci-venv, .test-venv, .canary-venv) now install deps
exclusively from the pinned requirements-ci.txt, then the project itself
with --no-deps — no floating escape hatch remains. The canary job's ad-hoc
'textual==8.2.8 textual-autocomplete==4.0.6 sqlite-vec || true' pre-install
is deleted: the lock pins exactly those versions. Lint job gains a
dependency-lock drift ratchet step (scripts/check_dep_lock.py) that fails
the job when the lock no longer matches pyproject.toml.
2026-09-07 14:11:14 +01:00
Nick 9df9181767 Merge remote-tracking branch 'gitea/master' into merge-robot
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 31s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:59:23 +01:00
conductor 9ce673f632 Merge pull request 'Pipeline gate jobs on desktop runner (latency)' (#202) from runner-desktop-gate into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-07 13:58:24 +01:00
Nick fd8bd001cb feat(pipeline): deterministic merge-robot — green PRs merge without agent turns (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 20s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 52s
CI Pipeline / canary (pull_request) Successful in 33s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:52:59 +01:00
Nick 160681cedd feat(ci): pinned CI dependency lock (#150)
requirements-ci.txt: fully-pinned output of
'uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra
memory --python-version 3.11' (plain pip format; runners need no uv).
scripts/check_dep_lock.py: drift ratchet — regenerates the compile into a
temp file, diffs requirement pins against the committed lock, exits 1 with
the regeneration command on drift. Unit-tested against fixture files.
2026-09-07 13:45:54 +01:00
Nick e33b563dad feat(pipeline): gate jobs on desktop runner — latency (Pi measured ~15min/6 API calls poisons event-driven gates) (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 26s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:31:24 +01:00
conductor f2dc1621fd Merge pull request 'Research gate: evidence enforcement (OUTWARD sources required)' (#201) from research-gate-evidence into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 58s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 1s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 12:49:16 +01:00
Nick b77f4b43ef feat(pipeline): synthesis kill-test requirement + loop-back bound with owner escalation (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 24s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 25s
CI Pipeline / unit-tests (pull_request) Successful in 33s
CI Pipeline / canary (pull_request) Successful in 32s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 12:40:07 +01:00
Nick e497bd8c56 feat(pipeline): synthesis qualification gate — evidence sufficiency, feasibility verdict, honest MORE-RESEARCH loop-back (#147)
CI Pipeline / lint (push) Successful in 25s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 26s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 32s
CI Pipeline / canary (pull_request) Successful in 1m30s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 12:32:30 +01:00
Nick 3e647281ce feat(pipeline): research gate verifies source reachability + per-source trail of use (#147)
CI Pipeline / lint (push) Successful in 44s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 43s
CI Pipeline / unit-tests (pull_request) Successful in 1m5s
CI Pipeline / canary (pull_request) Successful in 36s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 32s
CI Pipeline / gitea-offline (pull_request) Successful in 21s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 12:19:24 +01:00
Nick 25684bf1f0 feat(pipeline): research gate requires evidence (sources in OUTWARD, citations in INWARD) (#147)
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 31s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Successful in 27s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 12:13:34 +01:00
conductor 2840f71ccf Merge pull request 'P1-02: AttentionCapsule as the real model product (closes #25)' (#200) from p1-02-capsule-authority into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Successful in 29s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 20s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 10:03:58 +01:00
Nick b122aeb904 docs(p1-02): plan completion record (#25)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 09:59:03 +01:00
Nick 0434aac072 feat(p1-02): canary + CI registration (#25) 2026-09-07 09:59:03 +01:00
Nick 1db30c14f4 feat(p1-02): capsule authority map + docs (#25) 2026-09-07 09:59:03 +01:00
Nick 21c40231a5 feat(p1-02): trace v4 capsule fidelity fields (#25) 2026-09-07 09:59:03 +01:00
Nick 340646a7b1 feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25) 2026-09-07 09:59:03 +01:00
Nick 32f155d95b feat(p1-02): capsule content logging + message capture + legacy deprecation (#25) 2026-09-07 09:59:03 +01:00
conductor 6b191994d8 Merge pull request 'feat(pipeline): automatic stage transitions — in-progress, incomplete ledger, notification comments' (#198) from feat/pipeline-transitions into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 32s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / canary (push) Successful in 28s
Auto-label / auto-label (pull_request) Successful in 1s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 09:42:01 +01:00
conductor 4f9ddcde1f feat(pipeline): automatic stage transitions — in-progress label, incomplete-work catcher, notification comments
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 27s
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 25s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 09:34:32 +01:00
conductor 5c293df56b Merge pull request 'fix(pipeline): research gate wording — Rune does the research, not the user' (#195) from fix/research-gate-wording into master
CI Pipeline / lint (push) Successful in 26s
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 31s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 25s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 09:00:58 +01:00
conductor f1e17b03d4 fix(pipeline): research gate wording — Rune does the research, not the user
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 46s
CI Pipeline / unit-tests (push) Failing after 20s
CI Pipeline / canary (push) Successful in 41s
CI Pipeline / best-practices (push) Successful in 17s
CI Pipeline / documentation (push) Successful in 8s
CI Pipeline / type-check (push) Successful in 43s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 35s
CI Pipeline / unit-tests (pull_request) Successful in 45s
CI Pipeline / canary (pull_request) Successful in 38s
CI Pipeline / best-practices (pull_request) Successful in 10s
CI Pipeline / documentation (pull_request) Successful in 9s
CI Pipeline / type-check (pull_request) Successful in 29s
CI Pipeline / gitea-offline (pull_request) Successful in 17s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 08:46:25 +01:00
conductor dca51f1801 Merge pull request 'feat(pipeline): research-round gate — auto-apply research-pending on ready, auto-clear on 4-phase research comment' (#194) from feat/research-round-gate into master
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 32s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 21s
CI Pipeline / required-checks (push) Skipped
2026-09-06 23:55:03 +01:00
conductor 56177ee1a4 feat(pipeline): research-round gate — auto-apply research-pending on ready, auto-clear on 4-phase research comment (RESEARCH ROUND V3)
CI Pipeline / lint (push) Successful in 24s
Auto-label / auto-label (pull_request) Successful in 2s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 22s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 1s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 20s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-06 23:47:57 +01:00
32 changed files with 3277 additions and 231 deletions

No files matched your search

+35 -12
View File
@@ -14,10 +14,25 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Create lint environment from manifest
- name: Create lint environment from lock
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 -e ".[dev]"
.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
- 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 "")')
@@ -46,10 +61,12 @@ jobs:
steps:
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Create test environment from manifest
- name: Create test environment from lock
run: |
# #150: deps come ONLY from the pinned lock.
python3 -m venv .test-venv
.test-venv/bin/pip install --disable-pip-version-check -e ".[dev,memory]"
.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
- name: Test local tools and interstitial message objects
run: |
PYTHONPATH=src:. .test-venv/bin/python -m pytest tests/ -q
@@ -62,14 +79,14 @@ jobs:
steps:
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Create canary environment
- name: Create canary environment from lock
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 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]"
.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
- name: Run canary suite
run: |
FAILED=0
@@ -90,6 +107,7 @@ jobs:
src/rune/canary_p0_04.py \
src/rune/canary_p0_05.py \
src/rune/canary_p0_07.py \
src/rune/canary_p1_02.py \
src/rune/canary_p1_07.py \
src/rune/canary_p1_06.py \
src/rune/canary_p1_01.py \
@@ -177,10 +195,15 @@ jobs:
runs-on: rune-ci-desktop
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Create type-check environment from manifest
- name: Create type-check environment from lock
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 -e ".[dev,memory]"
.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
- name: Type-check with ratchet
run: |
.type-venv/bin/python scripts/type_check_ratchet.py
+38
View File
@@ -0,0 +1,38 @@
name: Merge robot
# Deterministic merge-on-green for this repo's PRs. No LLM, no polling by the
# agent: the forge event model does the watching, this job does the one action.
# Runs hourly + on CI completion events so green PRs merge without any agent
# turn being spent on "check CI, merge, clean up".
on:
schedule:
- cron: '23 * * * *'
workflow_dispatch: {}
workflow_run:
workflows: ["CI Pipeline", "Auto-version and Release"]
types: [completed]
permissions:
pull_requests: write
issues: write
contents: read
env:
OWNER: conductor
REPO: rune
jobs:
merge-green:
runs-on: rune-ci-desktop
steps:
- name: "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)
SERVER: ${{ gitea.server_url }}
run: |
python3 scripts/merge_robot.py "$GITEA_TOKEN" "$SERVER/api/v1/repos/conductor/rune"
+311 -3
View File
@@ -3,6 +3,8 @@ name: Pipeline Order Gate
on:
issues:
types: [labeled, closed]
issue_comment:
types: [created]
schedule:
# Hourly sweep: catches newly-eligible issues when a dependency closed
# while the teacher was down/not yet deployed (temporal gap fix).
@@ -16,7 +18,7 @@ jobs:
# `Depends on: #N` reference in its body is CLOSED. Otherwise Gitea
# blocks the stage (removes ready, adds blocked, names the blocker).
gate:
runs-on: rune-infra-pi
runs-on: rune-ci-desktop
steps:
- name: Verify dependency order before allowing ready
uses: actions/github-script@v7
@@ -58,9 +60,15 @@ jobs:
}
}
if (openDeps.length === 0) {
// Auto-apply research-pending: the issue cannot start until a
// 4-phase research round (INWARD / OUTWARD / INNOVATION / SYNTHESIS)
// is completed and posted as a comment.
await github.rest.issues.addLabels({
owner, repo, issue_number: issue.number, labels: ['research-pending'],
}).catch(e => { if (e.status !== 404) throw e; });
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: `🟢 **READY — order satisfied; Rune may start.** Dependencies: ${uniqueDeps.length ? uniqueDeps.map(n => '#' + n).join(', ') : 'none'}.`,
body: `🟢 **READY — order satisfied.** Dependencies: ${uniqueDeps.length ? uniqueDeps.map(n => '#' + n).join(', ') : 'none'}.\n\n📋 **Research round required** (RESEARCH ROUND V3 — 4 phases):\n1. **INWARD** — codebase analysis\n2. **OUTWARD** — state-of-the-art survey (libraries, papers, production systems; "is there anything BETTER?")\n3. **INNOVATION** — viability-filtered improvements (evaluate, optionally implement)\n4. **SYNTHESIS** — specific build instructions\n\nRune (the agent) performs the research and posts the findings as a comment on this issue. The \`research-pending\` label will be cleared automatically and the issue becomes startable.`,
});
return;
}
@@ -82,7 +90,7 @@ jobs:
# issue (open, has the orchestration-stage marker in its body, all deps
# closed) and label it ready — the order teacher keeps Rune on task.
teach:
runs-on: rune-infra-pi
runs-on: rune-ci-desktop
steps:
- name: Announce the next issue in dependency order
uses: actions/github-script@v7
@@ -154,3 +162,303 @@ jobs:
body: (() => { const closed = context.payload.issue ? ' (closed: #' + context.payload.issue.number + ')' : ' (scheduled sweep)'; return `🎓 **NEXT: #${next.number} — ${next.title}** — dependencies satisfied${closed}; Rune may start.`; })(),
});
core.info(`Announced #${next.number} as next.`);
# RESEARCH CLEAR: when a comment containing the 4-phase research markers
# lands on a research-pending issue, clear the label and announce start.
research-clear:
runs-on: rune-ci-desktop
if: github.event_name == 'issue_comment' && github.event.action == 'created'
steps:
- name: Clear research-pending when research round is posted
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (!issue) { core.info('no issue in payload'); return; }
const comment = context.payload.comment?.body || '';
// --- Phase header detection (case-insensitive) ---
const hasInward = /\bINWARD\b/i.test(comment);
const hasOutward = /\bOUTWARD\b/i.test(comment);
const hasInnovation = /\bINNOVATION\b/i.test(comment);
const hasSynthesis = /\bSYNTHESIS\b/i.test(comment);
if (!hasInward || !hasOutward || !hasInnovation || !hasSynthesis) {
core.info(`research-clear: comment on #${issue.number} missing phases (inward=${hasInward} outward=${hasOutward} innovation=${hasInnovation} synthesis=${hasSynthesis}) — skip`);
return;
}
// --- Evidence enforcement (per phase, section-scoped) ---
// Split the comment into phase sections on the phase headers.
function section(phase) {
const re = new RegExp(`\\b${phase}\\b[\\s\\S]*?(?=\\b(?:INWARD|OUTWARD|INNOVATION|SYNTHESIS)\\b|$)`, 'i');
const m = comment.match(re);
return m ? m[0] : '';
}
const forgeHost = context.payload.repository?.html_url ? new URL(context.payload.repository.html_url).host : '';
const externalUrlRe = /https?:\/\/[^\s)\]>]+/gi;
const codePathRe = /(?:^|\s)(?:src|tests|scripts|docs|\.gitea)\/[\w./-]+/gi;
const plansPathRe = /(?:^|\s)plans\/[\w./-]+/gi;
function uniqueExternalUrls(text) {
const found = text.match(externalUrlRe) || [];
const uniq = new Map(); // normalized key -> original URL (fetchable)
for (const u of found) {
try {
const parsed = new URL(u);
if (parsed.host && parsed.host !== forgeHost) {
const key = parsed.host + parsed.pathname;
if (!uniq.has(key)) uniq.set(key, u);
}
} catch (e) { /* skip malformed */ }
}
return uniq;
}
function codeCitations(text) {
return new Set((text.match(codePathRe) || []).map(s => s.trim()));
}
const inwardSec = section('INWARD');
const outwardSec = section('OUTWARD');
const innovationSec = section('INNOVATION');
const synthesisSec = section('SYNTHESIS');
const tailSec = innovationSec + '\n' + synthesisSec;
const inwardCites = codeCitations(inwardSec);
const outwardUrls = uniqueExternalUrls(outwardSec);
const tailUrls = uniqueExternalUrls(tailSec);
const tailPlans = (tailSec.match(plansPathRe) || []).length;
const missing = [];
if (inwardCites.size < 2) missing.push(`- **INWARD**: needs ≥2 codebase citations (paths like src/..., tests/..., .gitea/...) — found ${inwardCites.size}.`);
if (outwardUrls.size < 2) missing.push(`- **OUTWARD**: needs ≥2 UNIQUE external source URLs (not this forge) — found ${outwardUrls.size}. Research must pull real sources to learn from so later stages build on evidence.`);
if (tailUrls.size < 1 && tailPlans < 1) missing.push(`- **INNOVATION/SYNTHESIS**: needs ≥1 source URL reference or a plans/ artifact path tying synthesis back to evidence.`);
// --- INNOVATION must be source-tied: ≥1 external URL from OUTWARD
// re-referenced (proving candidates derive from pulled sources, not invention).
const innovationUrlKeys = [...uniqueExternalUrls(innovationSec).keys()];
const tiedToOutward = innovationUrlKeys.filter(k => outwardUrls.has(k));
if (outwardUrls.size >= 2 && tiedToOutward.length < 1) {
missing.push(`- **INNOVATION**: candidate approaches must derive from OUTWARD sources — re-reference ≥1 of the OUTWARD source URLs here and say what it contributed. Found 0 source-tied candidates.`);
}
// --- SYNTHESIS qualification gate: the synthesis must (a) declare its
// evidence sufficiency, (b) judge feasibility REALISTICALLY (mechanism +
// assumptions + failure modes), and (c) either commit to a plan artifact
// or honestly bounce to MORE RESEARCH. Evidence-insufficient synthesis
// MUST declare MORE RESEARCH — bluffing through fails the gate.
const SYNTH_REJECTS = [];
const suffMarker = /\bevidence[^\n]{0,40}(suffic|adequat|insuffic)|insufficient evidence|evidence gap/i;
const hasSufficiency = suffMarker.test(synthesisSec);
const moreResearch = /\bMORE[- ]RESEARCH\b/i.test(synthesisSec);
const hasPlanArtifact = /(?:^|\s)plans\/[\w./-]+/.test(synthesisSec);
if (moreResearch) {
// Honest loop-back path: open questions REQUIRED, feasibility verdict
// explicitly NOT required — a substantiated MORE-RESEARCH is the honest
// outcome; demanding feasibility prose would teach padding.
const openQs = (synthesisSec.match(/\?\s*$/gm) || []).length + (synthesisSec.match(/\bopen question|\bunknown|\bunresolved/gi) || []).length;
if (openQs < 1) {
SYNTH_REJECTS.push(`- **MORE-RESEARCH declared but unsubstantiated**: name the specific open questions/unknowns that require more research (at least one '?', 'open question', or 'unknown').`);
}
if (!hasSufficiency) {
SYNTH_REJECTS.push(`- **MORE-RESEARCH qualification**: state what evidence you have, what is missing, and why the gap blocks a design decision (evidence-sufficiency statement).`);
}
} else {
// Proceed-to-plan path: full qualification — sufficiency + feasibility + kill test.
if (!hasSufficiency) {
SYNTH_REJECTS.push(`- **SYNTHESIS qualification**: no evidence-sufficiency statement. State explicitly whether OUTWARD evidence is sufficient to design from, and why (e.g. 'Evidence sufficient: <2-3 sources converge on the mechanism>' or 'Evidence insufficient: <what specifically is missing>').`);
}
const feasMarker = /\bfeasib|\bwon'?t work\b|\bwill work\b|\brisk\b|\bfailure mode\b|\bassumption\b|\brealistic/i;
const hasFeasibility = feasMarker.test(synthesisSec);
if (!hasFeasibility) {
SYNTH_REJECTS.push(`- **SYNTHESIS qualification**: no feasibility judgement. State the working mechanism, the load-bearing assumptions, and the failure modes — and judge realistically whether the synthesis WILL WORK. Optimism is not evidence.`);
}
// Kill test: a feasibility claim must name its cheapest disproof.
// The plan must carry it as an early frozen test — prose is not a prediction.
const hasKillTest = /\bkill test\b|\bdisprov|\bfalsif/i.test(synthesisSec);
if (hasFeasibility && !hasKillTest) {
SYNTH_REJECTS.push(`- **SYNTHESIS kill test**: name the cheapest observation that would DISPROVE the feasibility claim (a 'kill test:' line). For workflow-only changes the protected CI run may be the kill test — say so explicitly. A claim with no disproof is not a claim.`);
}
if (!hasPlanArtifact) {
SYNTH_REJECTS.push(`- **SYNTHESIS disposition**: must reference a concrete plans/ artifact (ready to implement) or declare **MORE-RESEARCH** with the specific open questions. Neither found.`);
}
}
// --- Trail of use: each OUTWARD source must carry an inline annotation
// (what was learned from it) on the same line — the verifiable trail.
const annotated = [...outwardUrls.entries()].filter(([, url]) => {
const line = outwardSec.split('\n').find(l => l.includes(url));
if (!line) return false;
const prose = line.replace(externalUrlRe, '').replace(/[-*`>\[\]()]/g, '').trim();
return prose.length >= 15;
});
if (outwardUrls.size >= 2 && annotated.length < 2) {
missing.push(`- **OUTWARD trail**: each source needs an inline note of what it taught (URL + ≥15 chars of what was learned, same line) — ${annotated.length}/${outwardUrls.size} annotated. A bare URL list is not a research trail.`);
}
// --- Live verification: fetch up to 5 sources; dead/hallucinated links fail.
// 2xx/3xx = real; 403/405/406/429 = real-but-bot-blocked (count as verified);
// 404/410/DNS/timeout (after 1 retry) = dead.
const toVerify = [...outwardUrls.values()].slice(0, 5);
const dead = [];
for (const u of toVerify) {
let ok = false;
for (let attempt = 0; attempt < 2 && !ok; attempt++) {
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 8000);
const r = await fetch(u, { redirect: 'follow', signal: ctl.signal, headers: { 'User-Agent': 'rune-research-gate/1.0 (source verification)' } });
clearTimeout(t);
ok = (r.status < 400) || [403, 405, 406, 429].includes(r.status);
if (!ok && [404, 410].includes(r.status)) break; // definitively dead, no retry
} catch (e) {
clearTimeout(t);
if (attempt === 1) { /* second failure — stays dead */ }
}
}
if (!ok) dead.push(u);
}
if (dead.length > 0) {
missing.push(`- **OUTWARD verification**: source(s) failed live fetch: ${dead.join(', ')} — dead or unreachable links cannot serve as research evidence.`);
}
const labels0 = (issue.labels || []).map(l => l.name);
if (!labels0.includes('research-pending')) {
core.info(`research-clear: #${issue.number} has no research-pending label — skip`);
return;
}
if (missing.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🚫 **Research round REJECTED — evidence gate.** All 4 phase headers present, but the evidence floor is not met:\n\n${missing.join('\n')}\n\nresearch-pending stays. Post the missing evidence (real sources fetched, real paths read) and the gate will re-run on your next comment.`,
});
core.info(`research-clear: evidence gate rejected #${issue.number}: ${missing.length} gap(s)`);
return;
}
// --- Honest loop-back: synthesis declared MORE-RESEARCH with substantiated
// open questions and all evidence floors met. This is a SUCCESS for honesty:
// research-pending stays, the named unknowns drive the next research pass.
// Bound: after 2 prior loop-backs on this issue, escalate to the owner
// instead of cycling silently — infinite MORE-RESEARCH is dithering.
if (moreResearch) {
let priorLoopBacks = 0;
try {
const comments = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, per_page: 100,
});
priorLoopBacks = comments.data.filter(c => (c.body || '').includes('honest loop-back accepted')).length;
} catch (e) { core.info('loop-back count fetch failed: ' + (e && e.message)); }
if (priorLoopBacks >= 2) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `⏸️ **Loop-back bound reached (${priorLoopBacks} prior MORE-RESEARCH cycles).** The synthesis again declares MORE-RESEARCH with substantiated open questions. Escalating to the owner rather than cycling silently: this issue needs a human decision — narrow the scope, accept a design under uncertainty, or split the unknowns into their own research issues.`,
});
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['blocked'],
}).catch(e => { if (e.status !== 404) throw e; });
core.info(`research-clear: loop-back bound reached on #${issue.number} — escalated with blocked label.`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🔁 **Synthesis not ready — honest loop-back accepted (cycle ${priorLoopBacks + 1}).** Evidence floors met (INWARD citations: ${inwardCites.size}, OUTWARD sources: ${outwardUrls.size}, trail annotated: ${annotated.length}, live-verified: ${outwardUrls.size - dead.length}), but the synthesis itself declares MORE-RESEARCH with substantiated open questions.\n\nresearch-pending stays. Next round must resolve the named unknowns — a later synthesis must either produce a plans/ artifact or identify NEW unknowns. Bound: 2 loop-backs, then escalation.`,
});
core.info(`research-clear: honest MORE-RESEARCH loop-back on #${issue.number} (cycle ${priorLoopBacks + 1}) — research-pending retained.`);
return;
}
// Clear research-pending, add in-progress
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, name: 'research-pending',
});
} catch (e) { if (e.status !== 404) throw e; }
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['in-progress'],
}).catch(e => { if (e.status !== 404) throw e; });
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🔬 **Research round verified — synthesis qualified.** 4 phases + evidence floor met (INWARD citations: ${inwardCites.size}, OUTWARD sources: ${outwardUrls.size}, trail annotated: ${annotated.length}, live-fetch verified: ${outwardUrls.size - dead.length}); synthesis declared evidence ${hasSufficiency ? 'sufficiency statement ✓' : '✓'} + feasibility judgement ✓ and committed to a plans/ artifact.\n\n▶️ **in-progress applied** — Rune is now working on this issue. The pipeline will notify at each transition.`,
});
core.info(`research-clear: #${issue.number} research verified with evidence, label cleared.`);
# INCOMPLETE CATCHER: detect branches with commits but no PR, belonging to
# in-progress issues — auto-create draft PR with incomplete label + ledger.
# Runs on the hourly sweep (piggybacks on existing schedule trigger).
incomplete-catcher:
runs-on: rune-ci-desktop
steps:
- name: Detect abandoned in-progress work and create draft PRs
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// Find open issues with in-progress label
const issues = [];
for (let page = 1; ; page++) {
const res = await github.rest.issues.listForRepo({
owner, repo, state: 'open', labels: 'in-progress', per_page: 50, page,
});
issues.push(...res.data.filter(i => !i.pull_request));
if (res.data.length < 50) break;
}
if (issues.length === 0) { core.info('No in-progress issues.'); return; }
// Find open branches
const branches = await github.rest.repos.listBranches({ owner, repo, per_page: 100 });
const branchNames = branches.data.map(b => b.name);
// Find existing PRs
const prs = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
const prHeads = new Set(prs.data.map(p => p.head.ref));
for (const issue of issues) {
// Look for a branch matching the issue pattern
const branch = branchNames.find(b => b.includes(String(issue.number)) || b === issue.head_branch);
if (!branch || prHeads.has(branch)) continue;
// Branch exists, no PR yet — create draft PR with incomplete label
try {
const pr = await github.rest.pulls.create({
owner, repo,
title: `[INCOMPLETE] ${issue.title}`,
head: branch, base: 'master', draft: true,
body: [
'## Incomplete Work Ledger',
'',
`**Issue:** #${issue.number}`,
`**Branch:** ${branch}`,
`**Status:** Work in progress, paused mid-implementation.`,
'',
'### What exists',
'- See commits on this branch',
'',
'### What remains',
'- Resume implementation from where it left off',
'- Run gates when complete',
'',
'### Resume instructions',
'1. Check out this branch',
'2. Read the plan file in the worktree',
'3. Continue from the last commit',
'4. Mark ready when implementation is complete',
].join('\n'),
});
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.data.number, labels: ['incomplete'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: `📋 **Incomplete work captured** — draft PR #${pr.data.number} created from branch \`${branch}\`. Resume from there when ready.`,
});
core.info(`Created draft PR #${pr.data.number} for issue #${issue.number}`);
} catch (e) {
if (e.status !== 422) throw e; // 422 = PR already exists
}
}
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,160 @@
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
@@ -0,0 +1,115 @@
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,6 +1413,32 @@ 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
+4 -4
View File
@@ -103,10 +103,10 @@
"evidence": "REMOVED by P0-04 (#20): _run_warm_router is a documented no-op; the ContextBroker (#19) owns all model-facing context. See docs/pipeline-removal.md."
},
{
"path": "src/rune/attention_capsule.py:96-163",
"role": "Bounded working-set candidate",
"disposition": "dormant-until-dependency",
"evidence": "Creates a bounded capsule but does not own prompt construction."
"path": "src/rune/attention_capsule.py",
"role": "AttentionCapsule — the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
"disposition": "retain-authority",
"evidence": "P1-02 (#25): the capsule IS the product the model receives — the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
},
{
"path": "src/rune/inference_trace.py",
+67
View File
@@ -0,0 +1,67 @@
# Capsule Fidelity (P1-02, #25)
The AttentionCapsule is the **product the model receives** — not a
side-report. This document states the invariant, the mechanism, and how to
verify it.
## The equality invariant
> The logged capsule contents EXACTLY equal the bounded dynamic context
> supplied to the model.
Concretely, three renderings of one turn must be identical:
1. `capsule.to_messages()` — re-derived from the admitted-block ledger,
2. the message list passed to `LocalToolRuntime.run(...)` — captured
pre-run via `capsule.capture_messages()` → `capsule.sent_messages`,
3. `InferenceTrace.capsule_messages` — the durable JSONL log.
Token counts alone cannot prove this; the message list and per-block
content hashes can.
## Mechanism
- **Assembly:** `ContextBroker.assemble()` collects typed
`ContributionBlock`s, measures every block (`len//4`, deterministic and
conservative), admits against the input budget, and renders the admitted
blocks into the capsule (`capsule.blocks`).
- **Payload derivation:** `AttentionCapsule.to_messages()` re-derives the
chat message list from `capsule.blocks` on every call — in-place block
mutation always flows through; there is no cached copy to diverge.
- **Capture:** `RuneOperator._execute_on_model` calls
`capsule.capture_messages(capsule.to_messages())` immediately before
`runtime.run`. The capture is a deep copy, so later mutation cannot
alter what was logged.
- **Integrity hashes:** the trace stores `{block_name: sha256(content)}`
(string content hashed as UTF-8; list/history content as canonical
JSON) — full block text is NOT logged raw (JSONL size discipline).
- **Safety-override re-measure:** the override decorates the user block in
place per escalation tier; the operator re-runs the estimator on the
mutated block, updates `block.tokens`, the broker trace's per-block
entry, and `capsule.token_count`. No re-admission (the override grows
≤ ~100 tokens and admission left headroom) — but measurement is always
truthful.
- **num_ctx propagation:** the runtime receives
`num_ctx = capsule.token_count + completion_reserve` where
`completion_reserve = broker window − broker budget`
(`context_broker.default_budget` reserves the completion room). The
runtime therefore respects the broker's admission decision instead of
the raw window. #28 owns the broader model-aware budget work.
## Verification
- Frozen tests: `tests/test_p1_02_capsule_authority.py` — asserts
`trace.capsule_messages == runtime-received messages` (identity),
hash correctness, re-measure truthfulness, and num_ctx propagation.
- Canary: `python3 src/rune/canary_p1_02.py` prints
`P1-02 CAPSULE FIDELITY CANARY PASSED` (registered in
`.gitea/workflows/ci.yml`'s canary loop).
## Retired legacy path
`attention_capsule.create_capsule` and `AttentionCapsule.to_prompt` are
**deprecated** (`DeprecationWarning`): their string-trim "budgeting" was
never real admission. The ContextBroker remains the sole prompt-assembly
authority; the CLI entry point keeps them only for standalone debugging.
`ContributionBlock.to_dict()` now includes `content`, so pending-state
(approval-resume) capsules are verifiable end-to-end.
+13 -2
View File
@@ -17,11 +17,11 @@ is gitignored, P0-05 M6) — never committed. Consumers: #25, #28, #29.
Read back with `rune.inference_trace.read_traces()` or
`python3 -m rune.inference_trace [n]` (tail n records).
## Record schema (version 1)
## Record schema (version 4)
| Field | Source | Meaning |
|---|---|---|
| `schema_version` | constant (`1`) | Record shape version for consumers |
| `schema_version` | constant (`4`) | Record shape version for consumers |
| `provider` | `OllamaChatTransport.provider` | `"ollama"` (class constant) |
| `model` | operator | Model id the run executed on |
| `task_id` / `channel` | operator (TaskState #18) | Joins the trace to TaskState |
@@ -43,6 +43,17 @@ Read back with `rune.inference_trace.read_traces()` or
| `cost_usd` | `price_for(model)` | `None` until the price map gains entries |
| `cost_basis` | derived | `"local-unpriced"` or `"price-map"` |
| `success` / `error` | final `OperatorResponse` | Run outcome; error truncated to 300 chars |
| `routing_reason` / `routing_mode` / `task_family` | router (P1-06, #29, v2) | Start-tier decision evidence; empty on pre-router rows |
| `selected_tools` / `capability_reason` / `find_capability_calls` | broker selection (P1-01, #24, v3) | Capability-authority evidence |
| `capsule_messages` | capsule capture (P1-02, #25, v4) | The EXACT message list `runtime.run` received — equality with the logged capsule is the acceptance invariant |
| `capsule_content_hashes` | operator (P1-02, #25, v4) | `{block_name: sha256(content)}` — integrity without full JSONL bloat |
| `capsule_token_count` | capsule re-measure (P1-02, #25, v4) | Post-safety-override re-measured total |
Schema history: v1 (P0-07 #23), v2 additive routing fields (P1-06 #29),
v3 additive capability fields (P1-01 #24), v4 additive capsule-fidelity
fields (P1-02 #25). All bumps are additive with defaults — older rows
without the new keys still parse (`read_traces` consumers must tolerate
missing keys).
## Authority and single-source rule
+84
View File
@@ -0,0 +1,84 @@
# Umbrella system → henxels contracts + retroactive governance — design for confirmation
## Part 1: Retroactive application (ready to execute now)
The survey found six ungoverned surfaces. Proposed henxels rules, in the tool's
real schema, each following an existing pattern:
| Surface | Finding | Proposed henxel (keyed, real schema) |
|---|---|---|
| `shared/*.py` (24 files) | ~100% near-duplicates of `src/rune/` modules (the mtime-copy sync is the cause) | `changed_with`: when `shared/*.py` changes, `src/rune/` mirror must change too — **or better (Part 2) the dupes die** |
| `research/**` (63 files, 16 dirs) | No index; unlinked pages | `referenced_in: docs/research-index.md` + `links_resolve` — orphan research is tracked |
| `memory/` (runtime state tracked in git!) | `journal/events.jsonl` + runtime JSON in the repo — runtime data in version control | `forbidden_files: [./memory/journal/events.jsonl, ./memory/index/*.json]` after a one-time extraction (runtime state belongs to the deployed agent, not the codebase) |
| `backups/*.bak` (3 files) | Build backups committed | `forbidden_files: [./backups/**]` (git history IS the backup) |
| `specs/`, `reports/` | Type-ungoverned | `allowed_filetypes: [.md]` (matches the plans rule) |
| `survey/` (7 files) | Ungoverned | fold into research indexing or `allowed_filetypes: [.md, .yaml]` |
All are mechanical; the `memory/` extraction is the only one touching runtime —
I'd do it in the same PR with the agent's own journal-preservation discipline
(backup branch first).
## Part 2: Umbrella system → contracts (needs your confirmation on shape)
### What the umbrella system is (verified)
`shared/umbrella_update.py`: a hand-maintained manifest (`UMBRELLAS` dict) of six
functional umbrellas — **memory, governance, identity, growth, perception,
infrastructure** — each listing shared/scripts/canaries components plus a
`depends_on` DAG (memory→governance→identity; growth→memory+governance). Purpose:
say "update memory" and the system knows the affected files, runs the umbrella's
canaries, syncs shared→deploy targets by mtime, and commits.
### What's wrong (verified defects)
1. **Manifest drift**: `memory_selector.py` and every post-manifest module is in
NO umbrella; it references canaries (`canary_unbounded.py`,
`canary_drift_wiring.py`) that don't exist in the repo.
2. **Two-sources-of-truth by design**: `sync_shared_to_scripts()` mtime-copies
`shared/` → `~/.config/opencode/scripts/` + the old TUI dir. This is why
henxels flags ~100% duplicates. The repo and the harness both "own" copies.
3. **Hardcoded harness paths** (`~/.config/opencode/...`) — not repo-portable,
dead on the runner.
### Proposed design: three contracts, one removal
**Contract A — `umbrella_manifest_is_complete`** (new custom statement):
every `src/rune/*.py` and `shared/*.py` module must appear in the umbrella
manifest (or the coverage-debt ledger). New modules force an umbrella decision —
the manifest can never silently drift again. This is the umbrella's real
invariant: *the system knows what needs updating* — enforced.
**Contract B — `umbrella_canaries_exist`** (new custom statement): every canary
referenced by the manifest must exist in `src/rune/` and be CI-registered
(composes with the existing `ci_runs_all_canaries`). Kills defect 1's phantom
references.
**Contract C — `umbrella_dag_acyclic_and_declared`** (new custom statement):
parse `UMBRELLAS`, verify `depends_on` is a DAG (no cycles), every dependency
target exists. Cheap, structural, protects the reflex's core logic.
**The removal (your call):** the mtime-copy sync. Two honest options:
- **Option 1 (recommended): repo-as-source-of-truth.** The repo's `src/rune/` +
`shared/` become the ONLY governed copies. The deployed harness gets its
scripts from the pinned checkout (the update flow already force-checks-out
gitea/master). `sync_shared_to_scripts()` is retired; the
near-duplicate `shared/` files either become true imports of `src/rune` or
move to the harness (outside the repo). Contract A then governs ONE tree.
Cost: the harness-side scripts dir stops being independently editable —
which is the point.
- **Option 2: keep dual-location, gate the sync.** Keep the copies but add a
`must_be_in_sync`-style contract: shared/ and its mirrors must be
content-identical at commit time (henxels `changed_with` + a checksum
statement). Cost: permanent duplication machinery; drift only detectable,
never prevented.
### Sequencing
1. Retroactive henxels (Part 1) — one PR, mechanical.
2. Contracts A+B+C + the manifest modernized in-repo (move `UMBRELLAS` to
`config/umbrellas.json`, sourced by the script) — second PR.
3. Option 1's sync retirement — its own PR, after you confirm.
**Confirm**: Option 1 or 2 for the sync? And does the `memory/` runtime-state
extraction have your blessing (it touches the live journal)?
+127
View File
@@ -0,0 +1,127 @@
# P1-02 AttentionCapsule → Real Model Product — Implementation Plan (#25)
> **For agentic workers:** execution contract — FROZEN TESTS ARE THE SPEC. Implement verbatim; record every deviation in the plan tail. TDD red → green. Do not create/complete tasks; controller owns task state.
**Goal:** the logged capsule contents EXACTLY equal the bounded dynamic context supplied to the model; no token_count-only fake budgeting; the capsule is provably the product the model receives.
**Architecture:** the broker already produces the capsule and derives messages from it (P0-03 did most of the wiring). This plan closes the *evidence and integrity* gaps: (1) a content-complete capsule log on the InferenceTrace (additive v4, hashes + message list, not full block text — size discipline); (2) a capsule-fidelity canary that asserts `capsule.to_messages()` == the messages passed to `runtime.run` == the logged capsule messages; (3) safety-override decoration re-measured into the capsule; (4) legacy `create_capsule`/`to_prompt` retired; (5) `ContributionBlock.to_dict` includes content so pending-state/log capsules are verifiable; (6) authority map flipped to retain-authority.
**Tech stack:** stdlib + existing house modules. No new services, no parallel assembly.
## Global Constraints
- Acceptance criterion (from #25): logged capsule contents EXACTLY equal the bounded dynamic context supplied to model. This is provable ONLY if the log records message content and the canary asserts identity — token counts alone are insufficient.
- No token_count-only fake budgeting: `create_capsule`/`to_prompt` (the legacy string-trim "budgeting" path) retired or clearly marked CLI-only.
- Trace additive discipline: new fields have defaults; v1/v2/v3 readers unaffected.
- Degrade-not-crash: capsule logging must never fail the turn (P0-06).
- Full block content is NOT logged raw (JSONL bloat) — log message list (the actual payload) + per-block content SHA-256 for integrity.
- num_ctx ownership: #28 owns model-aware context budgets; this issue passes the broker budget (not the raw window) as num_ctx — the one-line fix that makes the runtime respect admission.
- Safety-override decorated user block MUST be re-measured so `capsule.token_count` stays truthful post-decoration.
- TDD; ruff check + format on touched/added files; canary registered in ci.yml same commit.
## Design Decisions (settled)
1. **Capsule log location:** InferenceTrace additive v4 fields — `capsule_messages: list[dict] = []` (the exact messages sent), `capsule_content_hashes: dict[str,str] = {}` (block name → SHA-256 of content), `capsule_token_count: int = 0` (post-decoration re-measured total). No separate JSONL file; rotation already handled.
2. **Equality invariant:** `capsule.to_messages()` result is captured BEFORE `runtime.run` and stored on the trace; a frozen test asserts trace.capsule_messages == the list passed to runtime.run (identity, not re-measure). The canary asserts the same against a scripted runtime.
3. **Safety-override re-measure:** after override mutates the user block content, re-run `estimate_tokens` on the mutated block, update `block.tokens` + `capsule.token_count` + the trace. No re-admission (override grows ≤ ~100 tokens; admission already left headroom).
4. **num_ctx:** pass `capsule.token_count + completion_reserve` where completion_reserve = the broker's reserved completion room (window − budget). One-line change; #28 owns the broader model-aware budget work.
5. **Runtime fallback tool prompt:** formally exempt — `_with_fallback_prompt` only fires for non-native models, and its cost is outside broker scope. Add a comment + log-only accounting note. Full integration is a #28/#34 follow-up.
6. **Legacy retirement:** `create_capsule` and `to_prompt` marked deprecated (docstring + `warnings.warn(DeprecationWarning)`), zero live callers verified by test. `to_dict` gains `content` field. Authority map: `attention_capsule.py` → retain-authority under ContextBroker.
## Tasks
### Task 1 — ContributionBlock.to_dict includes content + AttentionCapsule message capture
**Files:** modify `src/rune/context_broker.py` (to_dict + capsule), `src/rune/attention_capsule.py` (deprecation), test `tests/test_p1_02_capsule_authority.py`.
**Interfaces:**
- Produces: `ContributionBlock.to_dict()` includes `"content"`; `AttentionCapsule.capture_messages()` → stores the exact list on `capsule.sent_messages` (not a dataclass field — set post-hoc like the existing dynamic attrs); `create_capsule`/`to_prompt` emit DeprecationWarning.
- [ ] **Step 1: failing tests** — to_dict includes content matching the block's content; to_dict without content key in old fixture still loads (back-compat); capture_messages stores the list; to_messages re-derivation equals captured list for the same capsule; create_capsule raises DeprecationWarning.
- [ ] **Step 2: run → red**
- [ ] **Step 3: implement**
- [ ] **Step 4: green**
- [ ] **Step 5: commit** `feat(p1-02): capsule content logging + message capture + legacy deprecation (#25)`
### Task 2 — Safety-override re-measure + num_ctx budget fix
**Files:** modify `src/rune/rune_operator.py`, test file.
- [ ] **Step 1: failing tests** — after safety-override mutates the user block, `capsule.token_count` reflects the new estimate (not stale); trace `per_block_tokens["user"]` matches; `num_ctx` passed to runtime.run equals `capsule.token_count + completion_reserve` (not the raw window).
- [ ] **Step 2: red** → **Step 3: implement** → **Step 4: green**
- [ ] **Step 5: commit** `feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25)`
### Task 3 — Trace v4 capsule fields + operator logging
**Files:** modify `src/rune/inference_trace.py` (v4 fields), `src/rune/rune_operator.py` (log messages + hashes into trace), test file.
**Interfaces:** Consumes Task 1. Produces: `InferenceTrace.capsule_messages`, `capsule_content_hashes`, `capsule_token_count` (additive, defaults preserve v1–v3 readers).
- [ ] **Step 1: failing tests** — trace record carries capsule_messages equal to the messages the scripted runtime received; content hashes present and correct; old trace rows without v4 fields still parse.
- [ ] **Step 2: red** → **Step 3: implement** → **Step 4: green**; full suite regression (baseline 588+84).
- [ ] **Step 5: commit** `feat(p1-02): trace v4 capsule fidelity fields (#25)`
### Task 4 — Authority map + docs
**Files:** `config/v1_authority_map.json` (attention_capsule → retain-authority, evidence), `tests/test_v1_authority_map.py` (if pinned), `docs/capsule-fidelity.md` (create: what the capsule is, the equality invariant, how to verify, num_ctx propagation).
- [ ] Steps: map update → docs → commit `feat(p1-02): capsule authority map + docs (#25)`
### Task 5 — Canary + CI registration
**Files:** create `src/rune/canary_p1_02.py`; modify `.gitea/workflows/ci.yml` same commit.
- [ ] Canary: scripted operator run asserts (1) trace.capsule_messages == runtime-received messages (exact list identity), (2) capsule_token_count truthful post-decoration, (3) num_ctx == budget not window, (4) content hashes match block contents, (5) no DeprecationWarning suppressed. Prints `P1-02 CAPSULE FIDELITY CANARY PASSED`.
- [ ] Full suite; ruff; henxels; commit `feat(p1-02): canary + CI registration (#25)`
## Acceptance (from #25)
- Logged capsule contents EXACTLY equal the bounded dynamic context supplied to model — proven by trace v4 `capsule_messages` == `runtime.run` messages, asserted in frozen test + canary.
- No token_count-only fake budgeting — `create_capsule`/`to_prompt` deprecated, zero live callers verified; admission remains the real budget enforcement.
## Pipeline requirements checklist
- [x] TDD red→green (frozen tests per task)
- [x] ruff check + format on touched/added files
- [ ] Spec gate PASS; Quality gate Approved (discharges if needed)
- [x] Full-suite regression green (baseline 588+84 → 604+84, +16 new)
- [x] Canary green + registered same commit; henxels exit 0
- [ ] Closed with formatted comment + PR refs
## Completion Record (P1-02 implementer, 2026-09-06)
**Status:** all 5 tasks complete. Branch `p1-02-capsule-authority`, head `96e42ae`.
**Commits:**
1. `d0550b6` feat(p1-02): capsule content logging + message capture + legacy deprecation (#25)
2. `ec0cf7a` feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25)
3. `8ff6a83` feat(p1-02): trace v4 capsule fidelity fields (#25)
4. `90bcd59` feat(p1-02): capsule authority map + docs (#25)
5. `96e42ae` feat(p1-02): canary + CI registration (#25)
**Final counts:** 604 passed + 84 subtests (baseline 588+84; +16 new frozen tests, 0 regressions).
Canary: `P1-02 CAPSULE FIDELITY CANARY PASSED`. henxels exit 0 (21 pre-existing warnings).
**Red evidence** (initial run of tests/test_p1_02_capsule_authority.py, 14 failed / 2 passed):
`test_contribution_block_to_dict_includes_content`, `test_capture_messages_stores_the_exact_list`,
`test_create_capsule_emits_deprecation_warning`, `test_safety_override_reremeasures_capsule_token_count`,
`test_num_ctx_is_budget_plus_completion_reserve_not_window`, `test_inference_trace_schema_v4_fields`,
`test_operator_trace_record_carries_capsule_messages` (+7 more) — all red before their task's implementation, green after.
**Deviations from plan:**
1. **Schema v4 bump touched pinned literals:** `tests/test_p1_01_capability_authority.py` (3 assertions) and canaries
`canary_p1_01.py` / `canary_p1_06.py` pinned `schema_version == 3`. Updated those pins to 4 in the same Task-3 commit —
the additive discipline protects v1–v3 ROWS, not the literal; `docs/inference-trace.md` schema table updated to v4 with a schema history note.
2. **trace["blocks"] user-entry re-measure:** the frozen test demanded `per_block_tokens["user"]` match post-decoration; the
re-measure therefore also updates the admitted user entry in the broker trace dict (not only `block.tokens` + `capsule.token_count`).
3. **num_ctx degrade path:** added a `num_ctx <= 0` fallback to the discovered window for stand-in brokers (trace missing
window/budget keys) — degrade-not-crash, beyond the literal one-liner, pinned by `test_num_ctx_falls_back_to_window_when_budget_basis_missing`.
4. **Trace v4 extraction is Mock-hardened:** pre-existing frozen tests construct the capsule as a `Mock`; the operator's
hash/message extraction guards non-list/non-dict content and falls back gracefully (no fidelity evidence on stand-in capsules).
5. **`capture_messages` deep-copies** the sent list (test-pinned) so caller-side mutation can never diverge the logged payload.
**Reviewer flags:**
- `InferenceTrace.capsule_messages` logs the FULL message payload per run — accepted per plan (message list, not raw blocks);
watch JSONL rotation headroom if history blocks grow (#28 compaction bounds this).
- `docs/inference-trace.md` "Record schema (version 4)" supersedes the version-1 table heading; no other docs referenced v3.
- Authority map: `attention_capsule.py` entry now `retain-authority` under ContextBroker (context_construction);
`test_v1_authority_map.py` green (path existence, disposition vocabulary).
@@ -0,0 +1,92 @@
# #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.
@@ -0,0 +1,31 @@
# Research-gate evidence enforcement (pipeline hardening) — Plan
> **Contract:** controller-authored (controller-sized change), independent review gate + protected CI still apply. Conventional commit referencing the tranche epic #147 (§3.4-adjacent gate integrity). No deviation without plan-tail record.
## Goal
Nick's directive: research rounds must actually PULL sources so downstream stages work on evidence. Today `research-clear` clears on four regex keywords — a round of pure improvisation clears identically to a genuine one (observed this session). The gate must verify EVIDENCE per phase.
## Change (single job edit: `.gitea/workflows/pipeline-gate.yml` research-clear)
Split the comment body into phase sections (on the phase headers). Require:
- **INWARD:** ≥2 codebase citations matching `(src|tests|scripts|docs|.gitea)/...` path pattern (evidence the code was actually read).
- **OUTWARD:** ≥2 UNIQUE external http(s) URLs, excluding the forge's own host (self-references are not external sources) — evidence sources were actually pulled.
- **INNOVATION + SYNTHESIS:** ≥1 total URL reference OR a `plans/` path mention (the synthesis must tie back to sources or a concrete plan artifact).
- Missing evidence → the job posts a comment naming exactly what's missing (per phase), research-pending STAYS, no label transition. Existing success path unchanged otherwise.
## Non-goals
- No LLM judging in CI (anti-complexity; regex evidence floor only).
- No change to gate/teach jobs; no new labels.
## Tasks
1. Edit research-clear script: section splitter + evidence counters + rejection comment. Verify YAML parses; henxels exit 0; all canaries green (unchanged src → suite untouched but CI will run it on PR anyway).
2. Commit `feat(pipeline): research gate requires evidence (sources in OUTWARD, citations in INWARD) (#147)`; push; PR; protected merge.
## Acceptance
- A sourceless OUTWARD no longer clears the gate (rejection comment received).
- An evidenced round (URLs + citations) clears as before.
- Verified live: the next tranche research round (posted with general-3's pulled sources) passes the new gate.
+47
View File
@@ -0,0 +1,47 @@
# 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
@@ -0,0 +1,120 @@
# 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
@@ -0,0 +1,191 @@
#!/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())
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""merge_robot.py — deterministic merge-on-green for this repo (#203).
Extracted from the inline workflow script so its logic is TESTABLE
(bug-fix contract, docs/bugfix-contract.md: the silent no-op defect).
Behavior:
- GET /user first: verify the token's identity and log it. Scoped-token
failures are diagnosable from the job log instead of mysterious.
- Scan open PRs; for each: combined status must be success AND mergeable.
- Merge refusals (403/405/anything non-(200,405-already-merged)) FAIL THE JOB
with the full error body — never print-and-continue. A robot that cannot
merge must be visibly broken, not silently idle.
- 405 with "behind the base branch" is retried once after a self-update of
the head (informational), then fails loudly if still refused.
- Every scanned PR produces a decision line: merged / skipped(reason).
"""
from __future__ import annotations
import json
import sys
import time
import urllib.error
import urllib.request
from typing import Any
class MergeRefused(Exception):
"""A merge the robot expected to perform was refused by the forge."""
def _api(
base: str, token: str, path: str, method: str = "GET", data: Any = None, ok: tuple = (200,)
) -> tuple[int, Any]:
req = urllib.request.Request(
base + path,
data=json.dumps(data).encode() if data is not None else None,
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
status = r.status
except urllib.error.HTTPError as e:
status = e.code
body = e.read()
except urllib.error.URLError as e:
raise MergeRefused(f"forge unreachable at {path}: {e.reason}") from e
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
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")
login = user.get("login", "?")
print(f"merge-robot: authenticated as {login} (status {status})")
return login
def scan(base: str, token: str) -> list[dict]:
status, prs = _api(base, token, "/pulls?state=open&limit=50")
print(f"merge-robot: {len(prs)} open PR(s)")
return prs
def decide(base: str, token: str, pr: dict) -> str:
"""Return a decision line for one PR; raises MergeRefused on unexpected refusals."""
n = pr["number"]
sha = pr["head"]["sha"]
status, cs = _api(base, token, f"/commits/{sha}/status")
state = cs.get("state")
if state != "success":
return f"#{n}: skipped (status={state})"
status, detail = _api(base, token, f"/pulls/{n}")
if not detail.get("mergeable", False):
return f"#{n}: skipped (not mergeable)"
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
if mstatus == 200:
return f"#{n}: MERGED"
if mstatus == 405:
msg = str(mresp.get("message", ""))
if "behind" in msg.lower():
return f"#{n}: skipped (head behind base — needs branch update): {msg}"
raise MergeRefused(f"#{n} merge refused 405: {msg}")
if mstatus == 403:
raise MergeRefused(f"#{n} merge FORBIDDEN 403 — token lacks pull-request write scope: {str(mresp)[:300]}")
raise MergeRefused(f"#{n} merge failed {mstatus}: {str(mresp)[:300]}")
def main_with(argv: list[str]) -> int:
token = argv[1] if len(argv) > 1 else ""
base = argv[2] if len(argv) > 2 else ""
if not token or not base:
print("usage: merge_robot.py <token> <api-base>")
return 2
try:
verify_identity(base, token)
prs = scan(base, token)
merged = 0
for pr in prs:
line = decide(base, token, pr)
print("merge-robot:", line)
if "MERGED" in line:
merged += 1
time.sleep(1)
print(f"merge-robot: done — {merged} merged of {len(prs)} scanned")
return 0
except MergeRefused as exc:
print(f"merge-robot: FAILING JOB — {exc}", file=sys.stderr)
return 1
def main() -> int:
return main_with(sys.argv)
if __name__ == "__main__":
sys.exit(main())
+169
View File
@@ -0,0 +1,169 @@
#!/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())
+39 -2
View File
@@ -15,6 +15,7 @@ import json
import os
import sys
import re
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List
@@ -89,8 +90,32 @@ class AttentionCapsule:
messages.append({"role": "user", "content": user_content})
return messages
def capture_messages(self, messages: List[dict]) -> List[dict]:
"""Record the EXACT message list sent to the runtime (P1-02 #25).
Stored post-hoc on ``capsule.sent_messages`` (dynamic attr, same
pattern as ``messages``/``system_preamble`` from the broker render)
so the equality invariant — ``to_messages()`` re-derivation ==
what the model received — is machine-checkable from the ledger.
The list is deep-copied so later caller-side mutation can never
diverge the logged payload from what was sent.
"""
self.sent_messages = json.loads(json.dumps(list(messages)))
return self.sent_messages
def to_prompt(self) -> str:
"""Convert capsule to prompt text."""
"""Convert capsule to prompt text.
.. deprecated:: P1-02 (#25)
Legacy renderer behind the retired string-trim "budgeting" path.
The ContextBroker owns prompt assembly; use ``to_messages()``.
"""
warnings.warn(
"attention_capsule.AttentionCapsule.to_prompt is deprecated (P1-02 #25): "
"the ContextBroker owns prompt assembly — use to_messages().",
DeprecationWarning,
stacklevel=2,
)
parts = []
if self.active_objective:
@@ -135,9 +160,21 @@ def estimate_tokens(text: str) -> int:
def create_capsule(task: str, context: str = "", max_tokens: int = 600) -> AttentionCapsule:
"""Create an attention capsule for a task.
Only includes what's relevant to the current task.
.. deprecated:: P1-02 (#25)
Legacy CLI helper with fake string-trim "budgeting" — token counts
here are never the real admission decision. The ContextBroker
(#19) is the sole prompt-assembly authority. Kept only for the
standalone CLI above; no live callers.
"""
warnings.warn(
"attention_capsule.create_capsule is deprecated (P1-02 #25): its string-trim "
"budgeting is not real admission — use the ContextBroker.",
DeprecationWarning,
stacklevel=2,
)
capsule = AttentionCapsule()
# Extract objective from task
+2 -2
View File
@@ -247,8 +247,8 @@ def check_scripted_operator_wiring() -> int:
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 3:
return _fail(f"trace schema_version must be 3, got {record['schema_version']}")
if record["schema_version"] != 4:
return _fail(f"trace schema_version must be 4, got {record['schema_version']}")
if record["selected_tools"] != expected:
return _fail(f"trace selected_tools wrong: {record['selected_tools']}")
if not record["capability_reason"]:
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""P1-02 capsule-fidelity canary (#25).
Self-bootstrapping like canary_p1_07.py (CI runs ``python3 <canary>``
without PYTHONPATH). Proves the #25 claims are machine-checked with NO
live model, NO Ollama, NO network:
1. trace.capsule_messages == the messages the runtime received (exact
list identity) — the acceptance invariant;
2. capsule_token_count is truthful post-safety-override decoration;
3. num_ctx == capsule.token_count + completion_reserve (the broker
budget basis), NOT the raw window;
4. content hashes match the block contents;
5. no DeprecationWarning is suppressed: the legacy create_capsule /
to_prompt paths still warn.
Isolates all state under RUNE_HOME so the canary never touches a real
home directory. Prints 'P1-02 CAPSULE FIDELITY CANARY PASSED' and exits 0.
"""
import contextlib
import hashlib
import json
import os
import sys
import tempfile
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
# Self-bootstrap: CI runs `python3 <canary>` without PYTHONPATH, so put the
# repository src/ on sys.path before importing rune modules. Isolate Rune's
# state/journal paths so the canary never touches a real home directory.
_repo_src = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, _repo_src)
_canary_home = tempfile.mkdtemp(prefix="p1-02-canary-home-")
os.environ["RUNE_HOME"] = _canary_home
os.environ["RUNE_MEMORY_DIR"] = tempfile.mkdtemp(prefix="p1-02-canary-mem-")
from rune import rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ContributionBlock # noqa: E402
from rune.inference_trace import read_traces # noqa: E402
from rune.local_tool_runtime import OllamaChatTransport # noqa: E402
from rune.tool_protocol import OperatorResponse # noqa: E402
WINDOW = 40960
BUDGET = 32768
def _fail(message: str) -> int:
print(f"FAILED: {message}", file=sys.stderr)
return 1
class _ScriptedTransport(OllamaChatTransport):
"""OllamaChatTransport with _post scripted — real accumulation code runs."""
def __init__(self):
super().__init__()
def _post(self, path, payload):
return {
"model": "qwen3:8b",
"created_at": "2026-09-05T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": True,
"prompt_eval_count": 100,
"eval_count": 5,
"total_duration": 1_000_000_000,
}
class _ScriptedRuntime:
"""Minimal runtime stand-in: one chat, then the canned final response."""
def __init__(self, transport):
self.transport = transport
self.tool_names = ("read_file", "write_file")
self.received_messages: list = []
self.received_options: dict = {}
def run(self, messages, **kwargs):
self.received_messages = [dict(m) for m in messages]
self.received_options = dict(kwargs.get("options") or {})
self.transport.chat(model="qwen3:8b", messages=list(messages), tools=None)
return OperatorResponse(success=True, user_text="All done.", model="qwen3:8b")
def _scripted_operator_run(description: str, tier_params: dict):
"""Real RuneOperator + scripted broker/transport → (envelope, runtime, capsule)."""
storage = Path(_canary_home) / "operator.json"
op = rune_operator.RuneOperator(str(storage))
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", len("I am Rune.") // 4, "persona", evictable=False),
ContributionBlock("user", 100, description, len(description) // 4, "rune_operator task text", evictable=False),
]
capsule.token_count = sum(b.tokens for b in capsule.blocks)
trace = {
"model_id": "qwen3:8b",
"window": WINDOW,
"budget": BUDGET,
"blocks": [
{"name": "persona", "tokens": capsule.blocks[0].tokens, "admitted": True, "reason": ""},
{"name": "user", "tokens": capsule.blocks[1].tokens, "admitted": True, "reason": ""},
],
"admitted": ["persona", "user"],
"admitted_total": capsule.token_count,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
runtime = _ScriptedRuntime(_ScriptedTransport())
profile = SimpleNamespace(safety_tier=0)
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime),
patch.object(rune_operator, "OllamaChatTransport", return_value=runtime.transport),
patch("rune.local_tool_runtime.context_length", lambda model: WINDOW),
patch.object(rune_operator.safety_override.SafetyOverride, "get_params", lambda self, text: dict(tier_params)),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
]
with contextlib.ExitStack() as stack:
for item in patches:
stack.enter_context(item)
stack.enter_context(patch("rune.event_bus._store_path", lambda: Path(storage).parent / "journal-events.jsonl"))
envelope = op.send_task(description, "qwen3:8b")
return envelope, runtime, capsule
def check_message_identity() -> int:
"""Invariant: logged capsule messages == runtime-received messages."""
description = "inspect this code path"
envelope, runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["capsule_messages"] != runtime.received_messages:
return _fail("trace.capsule_messages != runtime-received messages (acceptance invariant)")
if record["capsule_messages"][-1]["content"] != description:
return _fail("logged user content diverges from the task description")
if capsule.sent_messages != capsule.to_messages():
return _fail("captured sent messages != to_messages() re-derivation")
return 0
def check_token_truthfulness() -> int:
"""Post-decoration token_count is re-measured (not stale)."""
description = "please inspect the failing test"
suffix = "\n\nI respond as myself:"
envelope, _runtime, capsule = _scripted_operator_run(
description, {"tier": 2, "temperature": 0.95, "prefix": "", "suffix": suffix}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
user_block = next(b for b in capsule.blocks if b.name == "user")
if user_block.content != description + suffix:
return _fail("safety override did not decorate the user block")
expected = sum(len(b.content) // 4 if isinstance(b.content, str) else 0 for b in capsule.blocks)
if capsule.token_count != expected:
return _fail(f"capsule.token_count {capsule.token_count} != re-measured {expected}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
if records[-1]["capsule_token_count"] != expected:
return _fail("trace capsule_token_count not truthful post-decoration")
return 0
def check_num_ctx_budget() -> int:
"""num_ctx == capsule.token_count + completion_reserve, never the window."""
description = "inspect this code path"
envelope, runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
completion_reserve = WINDOW - BUDGET
expected = capsule.token_count + completion_reserve
if runtime.received_options.get("num_ctx") != expected:
return _fail(f"num_ctx {runtime.received_options.get('num_ctx')} != budget basis {expected}")
if runtime.received_options.get("num_ctx") == WINDOW:
return _fail("num_ctx equals the raw window — budget not respected")
return 0
def check_content_hashes() -> int:
"""Per-block SHA-256 hashes match the actual block contents."""
description = "hash integrity probe"
envelope, _runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
hashes = records[-1]["capsule_content_hashes"]
if set(hashes) != {b.name for b in capsule.blocks}:
return _fail(f"hash keys {sorted(hashes)} != block names {sorted(b.name for b in capsule.blocks)}")
for block in capsule.blocks:
payload = (
block.content.encode("utf-8")
if isinstance(block.content, str)
else json.dumps(block.content, ensure_ascii=False, sort_keys=True).encode("utf-8")
)
if hashes[block.name] != hashlib.sha256(payload).hexdigest():
return _fail(f"content hash mismatch for block '{block.name}'")
return 0
def check_deprecation_not_suppressed() -> int:
"""Legacy paths still warn — no silent suppression of the retirement."""
from rune.attention_capsule import create_capsule
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
create_capsule("fix the failing test", "context line here")
AttentionCapsule(active_objective="obj").to_prompt()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
if len(deprecations) < 2:
return _fail(f"expected 2 DeprecationWarnings from legacy paths, got {len(deprecations)}")
return 0
def main() -> int:
checks = (
("trace.capsule_messages == runtime-received messages (identity)", check_message_identity),
("capsule_token_count truthful post-decoration", check_token_truthfulness),
("num_ctx == budget basis, not the raw window", check_num_ctx_budget),
("content hashes match block contents", check_content_hashes),
("legacy deprecation warnings not suppressed", check_deprecation_not_suppressed),
)
for label, check in checks:
result = check()
if result != 0:
return result
print(f" ✓ {label}")
print("P1-02 CAPSULE FIDELITY CANARY PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -198,8 +198,8 @@ def check_reason_recording() -> int:
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 3:
return _fail(f"schema_version must be 3, got {record['schema_version']}")
if record["schema_version"] != 4:
return _fail(f"schema_version must be 4, got {record['schema_version']}")
if record["routing_reason"] != "router: measured 12/13 success task_family=code":
return _fail(f"routing_reason not recorded: {record['routing_reason']!r}")
if record["routing_mode"] != "local":
+9 -1
View File
@@ -79,10 +79,18 @@ class ContributionBlock:
self.evictable = evictable # False = never dropped (system, user)
def to_dict(self) -> dict:
"""Serialize for pending_state embedding (approval resume)."""
"""Serialize for pending_state embedding (approval resume).
P1-02 (#25): ``content`` is included so the embedded capsule is
verifiable end-to-end — the acceptance criterion is that the logged
capsule contents EXACTLY equal the model payload, which token counts
alone cannot prove. Blocks are small (admission-bounded), so the
extra size is bounded by the same budget.
"""
return {
"name": self.name,
"priority": self.priority,
"content": self.content,
"tokens": self.tokens,
"source": self.source,
"evictable": self.evictable,
+8 -1
View File
@@ -25,7 +25,7 @@ try:
except ImportError: # direct script execution
from rune.memory_env import rune_home
INFERENCE_TRACE_SCHEMA_VERSION = 3
INFERENCE_TRACE_SCHEMA_VERSION = 4
TRACE_ROTATE_BYTES = 10_000_000
TRACE_BACKUPS = 3
@@ -87,6 +87,13 @@ class InferenceTrace:
selected_tools: list = field(default_factory=list) # ordered exposed set
capability_reason: str = "" # broker selection reason ("" = unselected)
find_capability_calls: int = 0 # escape-hatch round-trips this run
# P1-02 (#25) schema v4 — capsule-fidelity fields, additive. The logged
# capsule contents must EXACTLY equal the bounded dynamic context
# supplied to the model; message list (the actual payload) + per-block
# content SHA-256 (integrity without full JSONL bloat) prove it.
capsule_messages: list = field(default_factory=list) # exact runtime.run payload
capsule_content_hashes: dict = field(default_factory=dict) # block name -> sha256
capsule_token_count: int = 0 # post-decoration re-measured total
def price_for(model: str) -> float | None:
+75 -6
View File
@@ -13,6 +13,7 @@ Usage:
rune_operator.py list # List available models
"""
import hashlib
import json
import os
import re
@@ -1654,10 +1655,27 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Safety override decorates the final user BLOCK in place
# (P0-03 constraint d); rendering flows from the blocks.
# P1-02 (#25): after mutation the block is RE-MEASURED so
# capsule.token_count (and the broker trace's per-block
# entry) stay truthful post-decoration — the override grows
# the payload ≤ ~100 tokens and admission left headroom, so
# no re-admission is needed; measurement is not budgeting.
for block in capsule.blocks:
if block.name == "user":
block.content = override_prefix + task.description + override_suffix
block.tokens = len(block.content) // 4
for trace_block in trace["blocks"]:
if trace_block.get("name") == "user" and trace_block.get("admitted"):
trace_block["tokens"] = block.tokens
capsule.token_count = sum(
block.tokens for block in capsule.blocks if hasattr(block, "tokens")
)
messages = capsule.to_messages()
# P1-02 (#25) acceptance: the log must capture the EXACT
# payload the runtime receives — capture BEFORE run, assert
# identity in the frozen test + canary.
if hasattr(capsule, "capture_messages"):
capsule.capture_messages(messages)
runtime = LocalToolRuntime(
transport=OllamaChatTransport(timeout=timeout),
@@ -1666,18 +1684,27 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
tools=capability_selection["tools"],
)
transport = runtime.transport
# P0-03: the runtime window must match the broker's budget
# basis — a hardcoded 8192 would silently truncate blocks the
# broker admitted against a larger discovered window. The
# other 13 num_ctx sites remain pinned (follow-up issue).
from rune.local_tool_runtime import context_length as _ctx_len
# P1-02 (#25) one-line num_ctx fix: pass the broker budget
# basis — capsule.token_count + completion_reserve (window −
# budget) — NOT the raw window, so the runtime respects the
# broker's admission decision (#28 owns model-aware budgets).
completion_reserve = 0
if trace.get("window") is not None and trace.get("budget") is not None:
completion_reserve = max(0, int(trace["window"]) - int(trace["budget"]))
num_ctx = int(capsule.token_count) + completion_reserve
if num_ctx <= 0:
# Degrade-not-crash (stand-in broker/trace): keep the
# discovered window rather than sending num_ctx=0.
from rune.local_tool_runtime import context_length as _ctx_len
num_ctx = _ctx_len(model)
response = runtime.run(
messages,
options={
"temperature": temperature,
"num_predict": 4096,
"num_ctx": _ctx_len(model),
"num_ctx": num_ctx,
"stop": ["\n\nUser:", "\n\n---"],
},
think=True,
@@ -1790,6 +1817,35 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
record_trace,
)
# P1-02 (#25) schema v4: capsule fidelity evidence. The
# captured sent messages (post-decoration, pre-run) plus
# per-block SHA-256 integrity hashes and the re-measured
# token total. Degrade-not-crash (P0-06): a stand-in
# capsule (Mock/legacy) must never fail the trace —
# everything is guarded; the record just omits evidence
# that cannot be derived.
capsule_hashes: dict = {}
for _b in list(getattr(capsule, "blocks", None) or []):
content = getattr(_b, "content", None)
name = getattr(_b, "name", "")
if not isinstance(name, str) or not name or content is None:
continue
if isinstance(content, str):
payload = content.encode("utf-8")
elif isinstance(content, (list, dict)):
payload = json.dumps(content, ensure_ascii=False, sort_keys=True).encode("utf-8")
else:
continue
capsule_hashes[name] = hashlib.sha256(payload).hexdigest()
_sent = getattr(capsule, "sent_messages", None)
if not isinstance(_sent, list):
try:
_sent = capsule.to_messages()
except Exception:
_sent = []
if not isinstance(_sent, list):
_sent = []
trace_record = InferenceTrace(
schema_version=INFERENCE_TRACE_SCHEMA_VERSION,
provider=getattr(transport, "provider", "unknown"),
@@ -1831,6 +1887,19 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
find_capability_calls=sum(
1 for event in response.tool_events if event.request.name == "find_capability"
),
# P1-02 (#25) schema v4: capsule fidelity fields —
# the logged messages EXACTLY equal the runtime.run
# payload (captured pre-run on the mutated capsule).
capsule_messages=[
dict(m) for m in _sent if isinstance(m, dict)
],
capsule_content_hashes=capsule_hashes,
capsule_token_count=(
int(capsule.token_count)
if isinstance(getattr(capsule, "token_count", 0), int)
and not isinstance(getattr(capsule, "token_count", 0), bool)
else 0
),
)
record_trace(trace_record)
# P1-06 (quality I-2 discharge): CLOSE the measured loop —
+8 -193
View File
@@ -16,9 +16,6 @@ Usage:
"""
import json
import threading
import subprocess
import re
import logging
import os
import sys
@@ -104,7 +101,6 @@ try:
except ImportError:
BACKEND_AVAILABLE = False
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Header, Input, ProgressBar, RichLog, Static
@@ -985,76 +981,6 @@ def _resolve_model_name(setting: str, mode: str = "chat") -> str:
return "qwen2.5:3b" if setting == "local" else setting
def _semver_tuple(version: str):
"""'0.54.0' -> (0, 54, 0); unparseable -> () so comparisons fail closed."""
try:
return tuple(int(part) for part in str(version).strip().split("."))
except (TypeError, ValueError, AttributeError):
return ()
class UpdateChecker:
"""Active update detection for the live deploy (footer + refresh).
Compares the running ``rune.__version__`` against gitea/master (the
release authority) via the repo's own git remote — the credential lives
in the remote URL, nothing is hardcoded here. Degrade-not-crash: any
failure yields "unknown", never a false update flag.
"""
REPO = "/mnt/workdrive/rune"
CHECK_INTERVAL = 300 # seconds between background checks
def __init__(self):
self.update_available = False
self.latest_version = None
self.last_error = None
self._lock = threading.Lock()
self._checking = False
def _git(self, *args: str, timeout: int = 45):
return subprocess.run(
["git", "-C", self.REPO, *args],
capture_output=True, text=True, timeout=timeout,
)
def check(self) -> bool:
"""Fetch gitea/master and compare versions. True when a NEWER
version exists remotely. Thread-safe; single-flight."""
with self._lock:
if self._checking:
return self.update_available
self._checking = True
try:
fetch = self._git("fetch", "gitea", "master", timeout=60)
if fetch.returncode != 0:
raise RuntimeError(f"fetch failed: {fetch.stderr.strip()[:160]}")
show = self._git("show", "gitea/master:src/rune/__init__.py", timeout=20)
if show.returncode != 0:
raise RuntimeError(f"read remote version failed: {show.stderr.strip()[:160]}")
match = re.search(r'__version__\s*=\s*"([0-9.]+)"', show.stdout)
if not match:
raise RuntimeError("remote __version__ unparseable")
self.latest_version = match.group(1)
import rune as _rune_pkg
local = _rune_pkg.__version__
remote_t, local_t = _semver_tuple(self.latest_version), _semver_tuple(local)
self.update_available = bool(remote_t and local_t and remote_t > local_t)
self.last_error = None
except Exception as exc:
self.last_error = str(exc)[:200]
finally:
self._checking = False
return self.update_available
_update_checker = UpdateChecker()
class VersionLabel(Static):
"""Footer semver display — derived from the canonical __version__."""
@@ -1658,17 +1584,6 @@ class RuneApp(App):
refresh_btn = self.query_one("#refresh-btn", Static)
refresh_btn.tooltip = "Click to restart Rune with latest code"
version_label = self.query_one("#version-label", VersionLabel)
version_label.tooltip = "Running version. Checks gitea/master every 5 minutes."
def _update_check_worker(initial: bool = False):
try:
_update_checker.check()
self.call_from_thread(version_label.refresh)
except Exception:
pass
self.run_worker(_update_check_worker, thread=True, exclusive=False)
self.set_interval(UpdateChecker.CHECK_INTERVAL, _update_check_worker)
# Welcome messages
self.task_channel.add_message(
"system",
@@ -1802,6 +1717,14 @@ class RuneApp(App):
"system", f"[dim]⚠ Autonomy loop failed: {e}[/dim]"
)
def on_static_clicked(self, event: Static.Clicked) -> None:
"""Handle refresh button click."""
if event.widget.id == "refresh-btn":
import subprocess
self.exit()
subprocess.Popen(["rune"])
# Run independent startup tasks in parallel
# Note: These tasks return results; messages are added on the main thread
# to avoid Textual widget mutation from parallel threads.
@@ -4066,114 +3989,6 @@ class RuneApp(App):
except Exception:
pass
def on_click(self, event: events.Click) -> None:
"""Handle refresh button click (Textual 8.x: Static posts Click, not
Static.Clicked — the old on_static_clicked handler NEVER fired)."""
if getattr(event.widget, "id", None) != "refresh-btn":
return
if getattr(self, "_updating", False):
self._chat_or_notify("Update already in progress…")
return
self._updating = True
self._chat_or_notify("Checking for updates…")
def _do_update():
backup_branch = None
try:
fetch = _update_checker._git("fetch", "gitea", "master", timeout=60)
if fetch.returncode != 0:
self._chat_or_notify(f"Update failed: fetch error ({fetch.stderr.strip()[:120]})")
return
if not _update_checker.check():
import rune as _rune_pkg
self._chat_or_notify(
f"Already up to date (v{_rune_pkg.__version__})"
if not _update_checker.last_error
else f"Update check failed: {_update_checker.last_error}"
)
return
target = _update_checker.latest_version
# Safety: snapshot the current pinned state before moving it
backup_branch = f"backup/pinned-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
_update_checker._git("branch", backup_branch, timeout=20)
# Preserve runtime state across the update: the journal is
# tracked and locally modified — copy it out, force-checkout
# gitea/master (local commits are superseded by the release),
# then restore journal continuity.
import shutil
journal = os.path.join(UpdateChecker.REPO, "memory", "journal", "events.jsonl")
stamp = datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')
journal_backup = journal + f".pre-update-{stamp}"
if os.path.exists(journal):
shutil.copy2(journal, journal_backup)
# Untracked local files (NIGHT_LOG.md, state/, …) block a
# checkout when they exist in the target tree. Move them
# aside, checkout, then restore anything the release doesn't
# own — nothing is lost.
aside = os.path.join(UpdateChecker.REPO, f".pre-update-untracked-{stamp}")
os.makedirs(aside, exist_ok=True)
st = _update_checker._git("status", "--porcelain", timeout=20)
moved = []
for line in (st.stdout or "").splitlines():
if not line.startswith("??"):
continue
name = line[3:].strip().rstrip("/")
src = os.path.join(UpdateChecker.REPO, name)
if os.path.exists(src):
dst = os.path.join(aside, os.path.basename(name) or name.replace("/", "_"))
shutil.move(src, dst)
moved.append((src, dst))
co = _update_checker._git("checkout", "-f", "-B", "pinned-live", "gitea/master", timeout=120)
for src, dst in moved:
if not os.path.exists(src):
shutil.move(dst, src)
if co.returncode != 0:
self._chat_or_notify(
f"Update failed: checkout error ({co.stderr.strip()[:120]}). "
f"Local state preserved on {backup_branch}."
)
return
if os.path.exists(journal_backup):
shutil.copy2(journal_backup, journal)
self._chat_or_notify(f"Updated to v{target}. Restarting…")
import time as _time
_time.sleep(1.0)
# Restart: spawn the new instance FIRST (detached), then exit
# — if we exit first, a slow shell can kill the whole chain.
subprocess.Popen(
["bash", "-c", "cd /mnt/workdrive/rune/src && exec /home/nick/.venvs/memory/bin/python3 -m rune"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
)
self.exit()
except Exception as exc:
self._chat_or_notify(f"Update failed: {str(exc)[:160]}")
finally:
self._updating = False
self.run_worker(_do_update, thread=True, exclusive=False)
def _chat_or_notify(self, message: str) -> None:
"""Surface update-flow messages without crashing. ALWAYS marshals to
the UI thread — workers must never mount widgets directly (a worker
thread has no event loop; mounting from it raises)."""
def _post():
try:
self.notify(message, timeout=6)
except Exception:
try:
self.chat_channel.add_message("system", f"[dim]{message}[/dim]")
except Exception:
pass
try:
self.call_from_thread(_post)
except Exception:
# No running app loop (e.g. headless test) — best effort, no raise
pass
def action_focus_task(self) -> None:
"""Focus the task channel input."""
self.mode = "task"
+189
View File
@@ -0,0 +1,189 @@
"""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
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for scripts/merge_robot.py — the fail-loud merge-on-green logic.
Bug-fix contract red-first: the original inline robot swallowed merge refusals
(print-and-continue, exit 0) — the silent no-op defect. These tests freeze the
fail-loud behavior: refusals fail the job, identity is verified first, every
scan produces a decision line.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location("merge_robot", ROOT / "scripts" / "merge_robot.py")
mr = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(mr)
class FakeForge:
"""Simulates the SERVER contract (not the client's logic — no circularity)."""
def __init__(self, prs, statuses, merge_results):
self.prs = prs
self.statuses = statuses # sha -> state
self.merge_results = merge_results # pr_number -> (status, body)
self.mergeable: dict[int, bool] = {} # pr_number -> server-reported mergeable
self.calls: list[tuple[str, str]] = []
def get(self, path):
self.calls.append(("GET", path))
if path == "/user":
return 200, {"login": "gitea-actions-robot"}
if path.startswith("/pulls?state=open"):
return 200, self.prs
if path.startswith("/commits/"):
sha = path.split("/")[2]
return 200, {"state": self.statuses.get(sha, "pending")}
if path.startswith("/pulls/") and not path.endswith("/merge"):
n = int(path.split("/")[2])
for pr in self.prs:
if pr["number"] == n:
# server truth: GET /pulls/{n} includes mergeable (default True)
return 200, {**pr, "mergeable": self.mergeable.get(n, True)}
return 404, {}
raise AssertionError(f"unexpected GET {path}")
def post(self, path, data):
self.calls.append(("POST", path))
if path.endswith("/merge"):
n = int(path.split("/")[2])
return self.merge_results.get(n, (200, {}))
raise AssertionError(f"unexpected POST {path}")
@pytest.fixture
def forge(monkeypatch):
f = FakeForge([], {}, {})
def fake_api(base, token, path, method="GET", data=None, ok=(200,)):
f.calls.append((method, path))
if method == "GET":
status, body = f.get(path)
else:
status, body = f.post(path, data)
return status, body
monkeypatch.setattr(mr, "_api", fake_api)
return f
def _pr(n, sha, state="success", mergeable=True):
return {"number": n, "head": {"sha": sha}}, {sha: state}, mergeable
def test_identity_verified_before_any_merge(forge, capsys):
forge.prs = [{"number": 1, "head": {"sha": "a"}}]
forge.statuses = {"a": "pending"}
mr.main_with(["merge_robot.py", "tok", "http://x/api/v1/repos/o/r"])
assert ("GET", "/user") == forge.calls[0], "identity check must be the first call"
def test_merge_refusal_403_fails_job(forge, capsys):
forge.prs = [{"number": 7, "head": {"sha": "s7"}}]
forge.statuses = {"s7": "success"}
forge.merge_results = {7: (403, {"message": "token does not have required scope"})}
with pytest.raises(mr.MergeRefused, match="403"):
mr.decide("base", "tok", forge.prs[0])
def test_merge_refusal_405_unexpected_fails_job(forge):
forge.prs = [{"number": 8, "head": {"sha": "s8"}}]
forge.statuses = {"s8": "success"}
forge.merge_results = {8: (405, {"message": "protected branch rejects"})}
with pytest.raises(mr.MergeRefused, match="405"):
mr.decide("base", "tok", forge.prs[0])
def test_merge_success_returns_merged(forge):
forge.prs = [{"number": 9, "head": {"sha": "s9"}}]
forge.statuses = {"s9": "success"}
forge.merge_results = {9: (200, {})}
assert "MERGED" in mr.decide("base", "tok", forge.prs[0])
def test_pending_pr_skipped_not_merged(forge):
forge.prs = [{"number": 10, "head": {"sha": "s10"}}]
forge.statuses = {"s10": "pending"}
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
assert not any(m == "POST" for m, _ in forge.calls), "must not attempt merge on pending"
def test_conflict_pr_skipped(forge):
forge.prs = [{"number": 11, "head": {"sha": "s11"}}]
forge.statuses = {"s11": "success"}
forge.mergeable = {11: False} # server reports conflict
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
assert not any(m == "POST" for m, _ in forge.calls)
def test_behind_base_405_is_skip_not_crash(forge):
"""405 'behind the base branch' = needs branch update, not a robot failure."""
forge.prs = [{"number": 12, "head": {"sha": "s12"}}]
forge.statuses = {"s12": "success"}
forge.merge_results = {12: (405, {"message": "The head branch is behind the base branch"})}
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
def test_main_exits_1_on_refusal(forge, monkeypatch):
forge.prs = [{"number": 13, "head": {"sha": "s13"}}]
forge.statuses = {"s13": "success"}
forge.merge_results = {13: (403, {"message": "no scope"})}
def fake_scan(base, token):
return forge.prs
monkeypatch.setattr(mr, "scan", fake_scan)
rc = mr.main_with(["merge_robot.py", "tok", "base"])
assert rc == 1, "refusal must fail the job (exit 1) — the silent no-op defect"
def test_main_exits_0_all_merged(forge, monkeypatch):
forge.prs = [{"number": 14, "head": {"sha": "s14"}}]
forge.statuses = {"s14": "success"}
forge.merge_results = {14: (200, {})}
def fake_scan(base, token):
return forge.prs
monkeypatch.setattr(mr, "scan", fake_scan)
assert mr.main_with(["merge_robot.py", "tok", "base"]) == 0
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 -3
View File
@@ -326,7 +326,9 @@ def test_default_runtime_still_exposes_full_tools():
def test_inference_trace_schema_v3_fields():
from rune import inference_trace as it
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 3
# P1-02 (#25) bumped the schema to v4 (additive); assert against the
# module constant, not a pinned literal.
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
@@ -420,7 +422,7 @@ def test_operator_trace_record_carries_capability_fields(tmp_path, monkeypatch):
records = it.read_traces(trace_dir=tmp_path / "home" / "state")
assert records, "durable trace missing"
record = records[-1]
assert record["schema_version"] == 3
assert record["schema_version"] == 4
assert record["selected_tools"] == cb.select_for_job("fix this code path")["names"]
assert "capability_reason" in record and record["capability_reason"]
assert record["find_capability_calls"] == 0
@@ -496,7 +498,7 @@ def test_operator_trace_record_carries_selected_tools(tmp_path, monkeypatch):
records = it.read_traces(trace_dir=tmp_path / "home2" / "state")
assert records
record = records[-1]
assert record["schema_version"] == 3
assert record["schema_version"] == 4
assert record["selected_tools"] == tool_names
assert record["capability_reason"]
assert record["find_capability_calls"] == 0
+523
View File
@@ -0,0 +1,523 @@
"""P1-02 (#25): AttentionCapsule authority — FROZEN TESTS ARE THE SPEC.
Layers covered per plan task:
1. ContributionBlock.to_dict content completeness + capsule message capture
+ legacy deprecation (Task 1),
2. safety-override re-measure + num_ctx budget propagation (Task 2),
3. trace v4 capsule fidelity fields + operator logging (Task 3).
The canary (src/rune/canary_p1_02.py) re-proves the contract CI-side.
"""
from __future__ import annotations
import hashlib
import json
import sys
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rune import rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ContributionBlock, default_budget # noqa: E402
from rune.local_tool_runtime import OllamaChatTransport # noqa: E402
from rune.tool_protocol import OperatorResponse # noqa: E402
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
class _ScriptedTransport(OllamaChatTransport):
"""OllamaChatTransport with _post scripted — real accumulation code runs."""
def __init__(self, responses):
super().__init__()
self._responses = list(responses)
def _post(self, path, payload):
if not self._responses:
raise AssertionError("scripted transport exhausted")
return self._responses.pop(0)
def _chat_response(prompt_eval_count, eval_count, total_duration_ns):
return {
"model": "qwen3:8b",
"created_at": "2026-09-05T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": True,
"prompt_eval_count": prompt_eval_count,
"eval_count": eval_count,
"total_duration": total_duration_ns,
}
class _ScriptedRuntime:
"""Minimal runtime stand-in: one chat, then the canned final response."""
def __init__(self, transport, response):
self.transport = transport
self.tool_names = ("read_file", "write_file")
self._response = response
self.received_messages: list = []
self.received_options: dict = {}
def run(self, messages, **kwargs):
self.received_messages = [dict(m) for m in messages]
self.received_options = dict(kwargs.get("options") or {})
self.transport.chat(model="qwen3:8b", messages=list(messages), tools=None)
return self._response
def _broker_trace(**overrides):
trace = {
"model_id": "qwen3:8b",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 980, "admitted": True, "reason": ""},
{"name": "user", "tokens": 20, "admitted": True, "reason": ""},
],
"admitted": ["persona", "user"],
"admitted_total": 1000,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
trace.update(overrides)
return trace
def _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace):
"""Real RuneOperator; broker/collaborators scripted; RUNE_HOME sandboxed."""
monkeypatch.setenv("RUNE_HOME", str(tmp_path))
op = rune_operator.RuneOperator(str(tmp_path / "operator.json"))
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
profile = SimpleNamespace(safety_tier=0)
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime),
patch.object(rune_operator, "OllamaChatTransport", return_value=runtime.transport),
patch("rune.local_tool_runtime.context_length", lambda model: 40960),
patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
patch("rune.event_bus._store_path", lambda: tmp_path / "journal-events.jsonl"),
]
return op, patches
# ---------------------------------------------------------------------------
# Task 1 — ContributionBlock.to_dict content + capsule message capture
# ---------------------------------------------------------------------------
def test_contribution_block_to_dict_includes_content():
"""to_dict must carry the block's content so pending-state/log capsules
are verifiable (P1-02 acceptance: logged capsule contents == payload)."""
block = ContributionBlock("user", 100, "inspect this", 5, "rune_operator task text", evictable=False)
serialized = block.to_dict()
assert serialized["content"] == "inspect this"
assert serialized["name"] == "user"
assert serialized["priority"] == 100
assert serialized["tokens"] == 5
assert serialized["source"] == "rune_operator task text"
assert serialized["evictable"] is False
def test_contribution_block_to_dict_round_trips_list_content():
"""List-content (history) blocks serialize the message list verbatim."""
messages = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]
block = ContributionBlock("history", 30, messages, 10, "history")
assert block.to_dict()["content"] == messages
def test_capsule_to_dict_embeds_block_content():
"""Capsule to_dict round-trips the block content through the ledger."""
capsule = AttentionCapsule(token_count=5)
capsule.blocks = [ContributionBlock("user", 100, "do the thing", 3, "src", evictable=False)]
data = capsule.to_dict()
assert data["blocks"][0]["content"] == "do the thing"
assert json.loads(json.dumps(data)) == data # JSON-safe
def test_capture_messages_stores_the_exact_list():
"""capture_messages() stores the sent list post-hoc (dynamic attr like
messages/system_preamble) — no dataclass field, additive only."""
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", 2, "persona", evictable=False),
ContributionBlock("user", 100, "go", 1, "user", evictable=False),
]
sent = capsule.to_messages()
returned = capsule.capture_messages(sent)
assert returned == sent
assert capsule.sent_messages == sent
# capture after in-place mutation re-captures the mutated rendering
capsule.blocks[-1].content = "decorated go"
assert capsule.to_messages() != sent
capsule.capture_messages(capsule.to_messages())
assert capsule.sent_messages == capsule.to_messages()
def test_capture_messages_copies_so_later_mutation_does_not_diverge():
"""The stored list must not alias the caller's list (log integrity)."""
capsule = AttentionCapsule()
sent = [{"role": "user", "content": "go"}]
capsule.capture_messages(sent)
sent.append({"role": "assistant", "content": "hijack"})
assert capsule.sent_messages == [{"role": "user", "content": "go"}]
def test_to_messages_rederivation_equals_captured_list():
"""Equality invariant: to_messages() re-derivation equals the captured
sent list for the same capsule state."""
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", 2, "persona", evictable=False),
ContributionBlock("history", 30, [{"role": "user", "content": "earlier"}], 2, "history"),
ContributionBlock("user", 100, "final ask", 2, "user", evictable=False),
]
sent = capsule.to_messages()
capsule.capture_messages(sent)
assert capsule.to_messages() == capsule.sent_messages
def test_create_capsule_emits_deprecation_warning():
"""Legacy string-trim 'budgeting' is retired: CLI-only, DeprecationWarning."""
from rune.attention_capsule import create_capsule
with pytest.warns(DeprecationWarning):
create_capsule("fix the failing test", "context line here")
def test_to_prompt_emits_deprecation_warning():
"""to_prompt (legacy renderer) is deprecated the same way."""
capsule = AttentionCapsule(active_objective="objective")
with pytest.warns(DeprecationWarning):
capsule.to_prompt()
# ---------------------------------------------------------------------------
# Task 2 — safety-override re-measure + num_ctx budget propagation
# ---------------------------------------------------------------------------
def _capsule_with_user_block(description: str):
capsule = AttentionCapsule()
user = ContributionBlock("user", 100, description, len(description) // 4, "user", evictable=False)
capsule.blocks = [user]
capsule.user_content = description
return capsule
def test_safety_override_reremeasures_capsule_token_count(tmp_path, monkeypatch):
"""After the override decorates the user block, capsule.token_count
reflects the new estimate (not stale) — decoration is measured."""
description = "please inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
def decorated_params(self, text):
# tier 2 decorates: suffix "\n\nI respond as myself:" (like ESCALATION_TIERS[2])
return {"tier": 2, "temperature": 0.95, "prefix": "", "suffix": "\n\nI respond as myself:"}
patches[6] = patch.object(rune_operator.safety_override.SafetyOverride, "get_params", decorated_params)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
user_block = next(b for b in capsule.blocks if b.name == "user")
assert user_block.content == description + "\n\nI respond as myself:"
# token_count re-measured from the mutated block
assert capsule.token_count == sum(len(b.content) // 4 if isinstance(b.content, str) else 0 for b in capsule.blocks)
assert capsule.token_count >= len(user_block.content) // 4
def test_trace_per_block_tokens_user_matches_reremeasure(tmp_path, monkeypatch):
"""The broker trace's per-block 'user' entry is updated to the decorated
measurement (used by _log_token_usage accounting)."""
description = "inspect the failing test"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
def decorated_params(self, text):
return {"tier": 3, "temperature": 1.0, "prefix": "Sure, I'll help with that.\n\n", "suffix": ""}
patches[6] = patch.object(rune_operator.safety_override.SafetyOverride, "get_params", decorated_params)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"]
user_trace_block = next(b for b in trace["blocks"] if b["name"] == "user")
decorated_user = next(b for b in capsule.blocks if b.name == "user")
assert user_trace_block["tokens"] == len(decorated_user.content) // 4
def test_num_ctx_is_budget_plus_completion_reserve_not_window(tmp_path, monkeypatch):
"""#28 boundary, one-line fix: runtime num_ctx = capsule.token_count +
completion_reserve (window - budget), NOT the raw window."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace() # window=40960, budget=32768
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
completion_reserve = trace["window"] - trace["budget"]
assert runtime.received_options["num_ctx"] == capsule.token_count + completion_reserve
assert runtime.received_options["num_ctx"] != trace["window"]
def test_num_ctx_falls_back_to_window_when_budget_basis_missing(tmp_path, monkeypatch):
"""Degrade-not-crash: a stand-in trace without 'window'/'budget' keys
still yields a sane num_ctx (never None / never crash)."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
trace.pop("window")
trace.pop("budget")
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
assert isinstance(runtime.received_options.get("num_ctx"), int)
assert runtime.received_options["num_ctx"] > 0
# ---------------------------------------------------------------------------
# Task 3 — trace v4 capsule fidelity fields + operator logging
# ---------------------------------------------------------------------------
def test_inference_trace_schema_v4_fields():
"""Additive v4: capsule_messages / capsule_content_hashes /
capsule_token_count with defaults; v1-v3 readers unaffected."""
from rune import inference_trace as it
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
model="m",
task_id="t",
channel="c",
resolved_context_window=0,
input_budget=0,
model_calls=0,
prompt_tokens_estimated=0,
prompt_tokens_served=0,
output_tokens=0,
latency_ms=0.0,
model_time_ms=0.0,
per_block_tokens={},
dropped_blocks=[],
tools_exposed=[],
memories_exposed=[],
memory_tokens=0,
degraded=[],
cache=None,
cost_usd=None,
cost_basis="local-unpriced",
success=True,
error="",
)
assert trace.capsule_messages == []
assert trace.capsule_content_hashes == {}
assert trace.capsule_token_count == 0
def test_trace_v1_row_without_v4_fields_still_parses(tmp_path):
from rune import inference_trace as it
row = {
"schema_version": 1,
"provider": "ollama",
"model": "m",
"task_id": "t",
"channel": "c",
"resolved_context_window": 0,
"input_budget": 0,
"model_calls": 0,
"prompt_tokens_estimated": 0,
"prompt_tokens_served": 0,
"output_tokens": 0,
"latency_ms": 0.0,
"model_time_ms": 0.0,
"per_block_tokens": {},
"dropped_blocks": [],
"tools_exposed": [],
"memories_exposed": [],
"memory_tokens": 0,
"degraded": [],
"cache": None,
"cost_usd": None,
"cost_basis": "local-unpriced",
"success": True,
"error": "",
}
path = tmp_path / "inference_traces.jsonl"
path.write_text(json.dumps(row) + "\n", encoding="utf-8")
records = it.read_traces(trace_dir=tmp_path)
assert records == [row] # readers never require v4 keys
def test_operator_trace_record_carries_capsule_messages(tmp_path, monkeypatch):
"""The durable trace logs the EXACT messages the runtime received —
the acceptance criterion, machine-checked."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
from rune import inference_trace as it
records = it.read_traces(trace_dir=tmp_path / "state")
assert records, "durable trace missing"
record = records[-1]
assert record["capsule_messages"] == runtime.received_messages, (
"logged capsule messages must EXACTLY equal what runtime.run received"
)
assert record["capsule_messages"][-1]["content"] == description
assert record["capsule_token_count"] == capsule.token_count
def test_operator_trace_record_carries_content_hashes(tmp_path, monkeypatch):
"""Per-block SHA-256 of content — integrity without JSONL bloat."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"]
from rune import inference_trace as it
record = it.read_traces(trace_dir=tmp_path / "state")[-1]
for block in capsule.blocks:
expected = hashlib.sha256(
block.content.encode("utf-8")
if isinstance(block.content, str)
else json.dumps(block.content).encode("utf-8")
).hexdigest()
assert record["capsule_content_hashes"][block.name] == expected
+164
View File
@@ -0,0 +1,164 @@
"""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")]