Compare commits

..
Author SHA1 Message Date
Nick dc0c510736 Merge remote-tracking branch 'gitea/master' into coverage-ratchet
CI Pipeline / lint (pull_request) Successful in 27s
CI Pipeline / unit-tests (pull_request) Successful in 41s
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 24s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
auto-version.yml (push) Invalid workflow file
CI Pipeline / lint (push) Failing after 41s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / canary (push) Failing after 35s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / unit-tests (push) Failing after 56s
CI Pipeline / type-check (push) Successful in 38s
CI Pipeline / required-checks (push) Skipped
2026-09-07 21:01:22 +01:00
Nick aef8f551a8 docs(ci): #149 plan tail — deviations, kill-test evidence, completion record
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 28s
CI Pipeline / unit-tests (push) Successful in 38s
CI Pipeline / canary (push) Successful in 31s
CI Pipeline / best-practices (push) Successful in 2s
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
CI Pipeline / lint (pull_request) Successful in 27s
CI Pipeline / unit-tests (pull_request) Successful in 39s
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 24s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 17:41:02 +01:00
Nick 3f358a3b46 docs(ci): coverage ratchet workflow (#149)
README gains a 'Coverage ratchet' section next to the #150 lock docs:
both floors (global 29, changed-lines 70), the raise-never-lower
doctrine, why the omitted canary files don't count, the merge-base
diff-range-notation choice, and the local reproduction commands.
2026-09-07 17:39:48 +01:00
Nick 61142944c1 style(ci): ruff format test_ci_lock_contract.py (#149) 2026-09-07 17:37:35 +01:00
Nick abc4a1c13e feat(ci): coverage ratchet in CI test job (#149)
RED-first (test_pr_context_diff_is_against_merge_base): the diff-cover
invocation now pins --diff-range-notation .. so the changed-lines diff
is against the merge base rather than the PR merge-ref tip — Gitea
Actions checks out the merge ref, and the default three-dot notation
would measure unrelated merge-commit lines. --compare-branch
origin/master is retained (fetch-depth: 0 guarantees the ref exists in
both push and PR contexts); the explicit range notation is the
supported mechanism diff-cover's CLI offers for pinning the range.
2026-09-07 16:53:39 +01:00
Nick 111b2bf2b5 feat(ci): coverage floors + diff-cover ratchet (#149)
Both floors measured and committed: global 29% (.coveragerc [report]
fail_under, canary_* scripts omitted — they are CI-run scripts, not
imported code) and changed-lines 70% (diff-cover --fail-under in the CI
test job). doctrine: raise, never lower.

coverage + diff-cover join [project.optional-dependencies] dev and the
pinned CI lock is regenerated (uv pip compile ... --extra dev --extra
memory --python-version 3.11): coverage==7.16.0, diff-cover==10.5.1 plus
transitives. scripts/check_dep_lock.py passes (drift ratchet green).

tests/test_ci_lock_contract.py (RED-first): asserts the committed floors,
lock pins, lock-only install, fetch-depth: 0 on the test checkout, and
the coverage run / report --fail-under / xml / diff-cover step chain.
2026-09-07 16:47:30 +01:00
Nick e9ebdb4b01 docs(ci): gate-review discharge record in plan completion record (#150)
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 16:07:45 +01:00
Nick 30a8ff8ee0 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 16:07:45 +01:00
Nick b58fc27780 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 16:07:45 +01:00
Nick 433c4247b9 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 16:07:45 +01:00
Nick 05d46dede4 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 16:07:45 +01:00
Nick 21d36c4550 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 16:07:45 +01:00
Nick e3cee0eee2 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 16:07:45 +01:00
Nick 4cf73f5dc4 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 16:07:45 +01:00
Nick 01085b1dff 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 16:07:45 +01:00
Nick b7269f878d 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 16:07:45 +01:00
16 changed files with 386 additions and 770 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# #149 Coverage ratchet — both floors live here, and they only ever rise.
# Doctrine (issue #149 evidence trail): CPython devguide — "any increase is a
# good one"; a floor is committed once measured and never lowered afterwards.
#
# Global floor = [report] fail_under below (measured 2026-09-07 at the
# #149 baseline; raise it when coverage grows, never lower).
# Changed-lines floor = diff-cover --fail-under=70 in .gitea/workflows/ci.yml
# (changed code may not reduce tested coverage; 70% start,
# ratchetable upward after the first clean pass).
#
# Regenerating the CI lock after touching this file is NOT required (tool
# config, not a dependency), but the floors above are contract: edit them
# only to raise.
#
# Canary files are omitted: src/rune/canary_*.py are standalone CI
# verification scripts executed directly by the canary job, never imported by
# the test suite — counting them would make the global floor a fiction.
[run]
source = src/rune
omit =
tests/*
scripts/*
src/rune/canary_*.py
[report]
fail_under = 29
show_missing = False
+14 -2
View File
@@ -61,15 +61,27 @@ jobs:
steps:
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
# #149: diff-cover --compare-branch origin/master needs full history
# to compute the changed-line diff in PR (merge-ref) and push runs.
fetch-depth: 0
- 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 -r requirements-ci.txt
.test-venv/bin/pip install --disable-pip-version-check -e . --no-deps
- name: Test local tools and interstitial message objects
- name: Test with coverage (global floor + changed-lines ratchet)
run: |
PYTHONPATH=src:. .test-venv/bin/python -m pytest tests/ -q
# #149 coverage ratchet — both floors live in .coveragerc; raise,
# never lower. The changed-lines floor (70) gates every PR's own
# lines; the global floor (29, measured at the #149 baseline) gates
# the suite total.
PYTHONPATH=src:. .test-venv/bin/coverage run -m pytest tests/ -q
.test-venv/bin/coverage report --fail-under=29
.test-venv/bin/coverage xml
.test-venv/bin/diff-cover coverage.xml --compare-branch origin/master --fail-under=70 \
--diff-range-notation ..
canary:
runs-on: rune-ci-desktop
+1 -5
View File
@@ -26,13 +26,9 @@ jobs:
merge-green:
runs-on: rune-ci-desktop
steps:
- name: "Checkout repo — job never checked out, scripts/merge_robot.py missing, exit 2 since #211"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Merge PRs whose protected checks are green (fail-loud)
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }} # conductor token; GITEA_TOKEN is reserved (auto token, read-only)
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
SERVER: ${{ gitea.server_url }}
run: |
python3 scripts/merge_robot.py "$GITEA_TOKEN" "$SERVER/api/v1/repos/conductor/rune"
@@ -1,46 +0,0 @@
name: Research backlog sweep
# One-time (repeatable, idempotent) research-pending backfill for the standing
# backlog (#204 second slice). The gate labels only on ready EVENTS; issues
# that predate it were never labeled → never researched. This workflow runs
# scripts/research_backfill_sweep.py with the gate's own semantics:
# eligible = open, not blocked, not already research-pending,
# NO existing 4-phase research round (already-done stays done),
# all Depends-on server-verified closed.
# Dry-run by default (comments the eligible set on nothing, prints to log);
# workflow_dispatch with apply=true performs the labeling. Each application
# fires the research stage (label event) — so apply is the ON switch for
# backlog research.
on:
workflow_dispatch:
inputs:
apply:
description: "Apply labels (omit = dry-run report only)"
required: false
default: "false"
permissions:
issues: write
contents: read
jobs:
sweep:
runs-on: rune-ci-desktop
steps:
- name: Check out master
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Run sweep
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
SERVER: ${{ gitea.server_url }}
APPLY: ${{ gitea.event.inputs.apply || 'false' }}
run: |
ARGS="sweep --base $SERVER/api/v1/repos/conductor/rune --token $GITEA_TOKEN"
if [ "$APPLY" = "true" ]; then
ARGS="$ARGS --apply"
fi
python3 scripts/research_backfill_sweep.py $ARGS
@@ -1,160 +0,0 @@
name: Research stage (headless)
# #204 second slice — RESEARCH STRUCTURALIZATION (Nick's directive, 2026-09-07):
# research is INITIATED by the forge, not by controller attention.
#
# When an issue gains `research-pending` (ready-event or backfill sweep), this
# stage runs a headless mimo agent that performs the RESEARCH ROUND V3 —
# four phases (INWARD / OUTWARD / INNOVATION / SYNTHESIS) — and posts ONE
# issue comment carrying all four phase markers, which the gate's
# research-clear job detects and clears the label (issue becomes startable).
#
# Filing contract (Nick): the full round is also saved as a research file
# (research/issue-<n>-research.md) committed to master by the agent, so
# findings are version-controlled and greppable — comments notify, files
# endure. SYNTHESIS must end in concrete build instructions: the stage's
# output IS the prompt that informs the build.
#
# Fail-loud: infrastructure errors fail the job; only a genuine empty agent
# output gets the fallback comment.
on:
issues:
types: [labeled]
permissions:
issues: write
contents: write
concurrency:
group: research-stage-${{ gitea.event.issue.number }}
cancel-in-progress: false
jobs:
research:
runs-on: rune-ci-desktop
if: gitea.event.label.name == 'research-pending'
steps:
- name: Check out master (research reads the codebase, not a PR)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Run headless research round (4 phases)
env:
MIMOCODE_AUTO_APPROVE_DELETE: "1"
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS: "1"
HOME: /var/lib/gitea-desktop-runner
XDG_CONFIG_HOME: /home/nick/.config
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
ISSUE_TITLE: ${{ gitea.event.issue.title }}
ISSUE_BODY: ${{ gitea.event.issue.body }}
run: |
# Prompt = governed repo template (prompts/research-round.md) with
# placeholders substituted. The template is the TUI-callback seam:
# future L3 (Rune TUI) owns/overrides it via reviewed diffs.
python3 - <<'PY'
import os
tpl = open("prompts/research-round.md").read()
out = (tpl.replace("{{ISSUE_NUMBER}}", os.environ["ISSUE_NUMBER"])
.replace("{{ISSUE_TITLE}}", os.environ["ISSUE_TITLE"])
.replace("{{ISSUE_BODY}}", os.environ["ISSUE_BODY"] or "(empty body)"))
open("/tmp/research_prompt.txt", "w").write(out)
print(f"prompt built: {len(out)} chars from prompts/research-round.md")
PY
/var/lib/gitea-desktop-runner/bin/mimo run \
-m "Verboo Code/glm-5.3-flash" \
--dangerously-skip-permissions \
--format json \
"$(cat /tmp/research_prompt.txt)" \
> /tmp/research_events.jsonl 2>/tmp/research_err.log
# Extract final assistant text (fail-loud: no swallow)
python3 - <<'PY'
import json
text = ""
try:
for line in open("/tmp/research_events.jsonl"):
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
for part in ev["message"]["content"]:
if part.get("type") == "text":
text = part.get("text", text)
except FileNotFoundError:
pass
if not text.strip():
raise SystemExit("research stage: agent produced NO text — infrastructure or model failure (fail-loud)")
open("/tmp/research_round.md", "w").write(text)
PY
- name: File research as comment + versioned research file
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
SERVER: ${{ gitea.server_url }}
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
run: |
python3 - <<'PY'
import json, os, subprocess, urllib.request
token = os.environ["GITEA_TOKEN"]
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
n = os.environ["ISSUE_NUMBER"]
round_md = open("/tmp/research_round.md").read().strip()
# 1. Comment (the marker the research-clear job keys on)
body = (f"🤖 **Headless research round (4-phase, #204 second slice)**\n\n{round_md}\n\n"
"<details><summary>Filed</summary>This round is also filed as "
f"research/issue-{n}-research.md on master (version-controlled, greppable). "
"research-pending will clear via the gate.</details>")
req = urllib.request.Request(
f"{base}/issues/{n}/comments",
data=json.dumps({"body": body}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
print(f"research comment posted: {r.status}")
# 2. Research file committed straight to master by the robot identity
subprocess.run(["git", "config", "user.name", "conductor"], check=True)
subprocess.run(["git", "config", "user.email", "nick@thingscouldgetdicey.online"], check=True)
subprocess.run(["git", "remote", "set-url", "origin",
f"https://conductor:{token}@gitea.thingscouldgetdicey.online/conductor/rune.git"], check=True)
subprocess.run(["git", "fetch", "origin", "master"], check=True)
subprocess.run(["git", "checkout", "-B", f"research-file-{n}", "origin/master"], check=True)
path = f"research/issue-{n}-research.md"
os.makedirs("research", exist_ok=True)
header = (f"Research round for #{n} (4-phase, agent-generated, "
"filed by research stage per #204; comment on the issue is the "
"research-clear trigger)\n\n---\n\n")
open(path, "w").write(header + round_md + "\n")
subprocess.run(["git", "add", path], check=True)
subprocess.run(["git", "commit", "-m",
f"docs(research): file 4-phase research round for #{n} (headless stage)"], check=True)
subprocess.run(["git", "push", "origin", f"HEAD:research-file-{n}", "-f"], check=True)
# Merge the one-file branch straight into master (contents:write scope)
mr = urllib.request.Request(
f"{base}/pulls",
data=json.dumps({"title": f"research file for #{n}",
"head": f"research-file-{n}", "base": "master",
"body": "Auto-filed research round (research stage)."}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(mr, timeout=30) as r:
pr = json.loads(r.read())
merge = urllib.request.Request(
f"{base}/pulls/{pr['number']}/merge",
data=json.dumps({"Do": "merge"}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(merge, timeout=30) as r:
print(f"research file merged to master via PR #{pr['number']}: {r.status}")
except urllib.error.HTTPError as e:
# PR may already exist from a re-run; merge it directly
print(f"PR create/merge skipped: {e.code} {e.read()[:200]}")
PY
-115
View File
@@ -1,115 +0,0 @@
name: Spec gate (headless)
# First slice of #204: agent cognition as a scoped, event-triggered Actions
# stage. A PR opens → this job runs a single self-contained review prompt
# (black-box doctrine per docs/adversarial-review.md — the reviewer answers
# the mandatory question BEFORE reading the implementation) → posts a
# Conventional-Comments verdict as a NON-BLOCKING PR comment.
#
# Deterministic floors underneath (untouched): required checks still gate the
# merge; this stage adds judgment, never replaces the floors.
on:
pull_request:
types: [opened, synchronize]
branches: [master]
permissions:
issues: write
pull_requests: write
contents: read
jobs:
spec-gate:
runs-on: rune-ci-desktop
# Skip draft PRs and our own automation PRs (bump PRs have no reviewable logic)
if: ${{ !startsWith(gitea.event.pull_request.title, 'chore(release)') }}
steps:
- name: Check out PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Run headless spec-gate review
env:
MIMOCODE_AUTO_APPROVE_DELETE: "1"
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS: "1"
HOME: /var/lib/gitea-desktop-runner
XDG_CONFIG_HOME: /home/nick/.config
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
run: |
BASE_SHA="$(git merge-base origin/master HEAD)"
{
echo "## Spec-gate review request"
echo ""
echo "You are the INDEPENDENT spec-compliance reviewer for this PR."
echo ""
echo "MANDATORY FIRST STEP (docs/adversarial-review.md): answer, in writing,"
echo "'What incorrect implementation could still pass these tests?' BEFORE"
echo "reading the implementation diff. Enumerate the wrong implementations"
echo "the existing tests would not catch."
echo ""
echo "Then: read the diff (git diff $BASE_SHA..HEAD), the changed files, and"
echo "any plan files under plans/ referenced in the PR body. Check:"
echo "1. Acceptance criteria (from the issue) — PASS/FAIL per criterion with file:line evidence."
echo "2. Deviation honesty — are recorded deviations sound? Any weakening of acceptance?"
echo "3. Test honesty — behavior-asserting or implementation-coupled? Vacuous assertions?"
echo "4. Silent failure — every except re-raises/logs/spools?"
echo ""
echo "Output format (Conventional Comments):"
echo "- Verdict line: 'Verdict: PASS' or 'Verdict: PASS-with-findings' or 'Verdict: Changes-required'"
echo "- Findings as: [BLOCKER]/[MAJOR]/[MINOR] — description (file:line)"
echo "- The mandatory-question answer, explicitly labeled."
echo "Be adversarial. Be specific. Under 500 words."
} > /tmp/spec_gate_prompt.txt
/var/lib/gitea-desktop-runner/bin/mimo run \
-m "Verboo Code/glm-5.3-flash" \
--dangerously-skip-permissions \
--format json \
"$(cat /tmp/spec_gate_prompt.txt)" \
> /tmp/spec_gate_events.jsonl 2>/tmp/spec_gate_err.log
# Extract the final assistant text from the JSONL event stream
python3 - <<'PY'
import json
text = ""
try:
for line in open("/tmp/spec_gate_events.jsonl"):
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
for part in ev["message"]["content"]:
if part.get("type") == "text":
text = part.get("text", text)
except FileNotFoundError:
pass
open("/tmp/spec_gate_verdict.md", "w").write(text or "Spec-gate agent produced no text output — check runner logs.")
PY
- name: Post verdict as non-blocking PR comment
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }} # GITEA_TOKEN is reserved (read-only auto token)
SERVER: ${{ gitea.server_url }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
python3 - <<'PY'
import json, os, urllib.request
token = os.environ["GITEA_TOKEN"]
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
pr = os.environ["PR_NUMBER"]
body = open("/tmp/spec_gate_verdict.md").read().strip()
body = ("🤖 **Headless spec gate (non-blocking, #204 first slice)**\n\n" + body +
"\n\n<details><summary>Deterministic floors still govern the merge</summary>\n\n"
"This comment is advisory. Required checks (lint, tests, canaries, "
"type-check, coverage, dependency lock, impact gate) remain the merge "
"authority per docs/adversarial-review.md proportionality.</details>")
req = urllib.request.Request(
f"{base}/issues/{pr}/comments",
data=json.dumps({"body": body}).encode(),
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
print(f"verdict posted: {r.status}")
PY
+34
View File
@@ -1439,6 +1439,40 @@ The lint job runs `scripts/check_dep_lock.py` as a drift ratchet: if
regeneration command — the lock is reviewed and re-committed, never
auto-regenerated.
### Coverage ratchet
The CI test job (issue #149) gates coverage with two floors. Both live in
`.coveragerc` (the changed-lines floor as a documented value, enforced by the
`diff-cover` invocation in `.gitea/workflows/ci.yml`), and both follow the
CPython ratchet doctrine: **raise, never lower**.
1. **Global floor — `coverage report --fail-under=29`.** Total suite coverage
over `src/rune` (canary scripts and tests/scripts omitted — canaries are
CI-run verification scripts, not imported code), measured and committed at
the #149 baseline. If coverage grows, raise the number in `.coveragerc`;
CI fails if it ever drops.
2. **Changed-lines floor — `diff-cover coverage.xml --compare-branch
origin/master --fail-under=70 --diff-range-notation ..`.** Every line a PR
adds or modifies must be covered at ≥ 70% (ratchetable upward after a
clean pass). The `..` notation diffs against the merge base so a PR is
measured on its own lines, not unrelated merge-ref history; the checkout
uses `fetch-depth: 0` so `origin/master` exists in CI.
Raising a floor: edit `.coveragerc` (`[report] fail_under`) and/or the
`--fail-under` value in `.gitea/workflows/ci.yml`, then commit. Never lower
either number. The contract is enforced by `tests/test_ci_lock_contract.py`,
which fails if a floor disappears from CI or is committed below the
baselines (29 global / 70 changed-lines).
To reproduce the CI coverage run locally:
```bash
coverage run -m pytest tests/ # or: .venv/bin/coverage run ...
coverage report --fail-under=29 # global floor
coverage xml
diff-cover coverage.xml --compare-branch origin/master --fail-under=70 --diff-range-notation ..
```
---
## Configuration
+129
View File
@@ -0,0 +1,129 @@
# #149 Coverage ratchet: changed code may not reduce tested coverage — Plan
> **Contract:** TDD; controller owns state; conventional commits (#149); PR + protected merge; deviations in plan tail. The kill test: a PR that REDUCES changed-line coverage must FAIL CI, and a coverage-raising PR must PASS.
## Goal
Changed/critical code may not reduce tested coverage (handoff §3.2). Implementation follows the OUTWARD evidence already on the issue: **diff-cover** (`--fail-under` on new/modified lines, `--compare-branch`) is the purpose-built changed-code ratchet; **coverage.py `fail_under`** is a global floor only. CPython's "any increase is a good one" is the floor doctrine.
## Architecture
1. **`requirements-ci.txt` regen** adds `diff-cover` + `coverage` (pinned) — the lock (#150, now live) is the install path.
2. **CI (ci.yml) test job:** after pytest, run `coverage run -m pytest` (or `--cov` via coverage's own RC — no pytest-cpy plugin dependency; coverage.py RC `[run] source = src/rune`), then `coverage report --fail-under=<committed floor>` (floor starts at current measured total, committed to `.coveragerc`, CI only ever raises it), then `diff-cover coverage.xml --compare-branch origin/master --fail-under=70` (changed-lines floor: 70% initial, ratchetable upward).
3. **Config:** `.coveragerc` (or pyproject `[tool.coverage.*]`) — source=src/rune, omit tests/scripts; xml report for diff-cover. Both floors in one committed place with a header comment: "raise, never lower".
4. **Drift-guard symmetry:** the lock's drift ratchet pattern (fail-on-regression, never auto-mutate) is reused — no new philosophy.
## Design decisions (settled)
1. diff-cover for changed lines (evidence on issue), coverage.py fail_under for the global floor — the two mechanisms answer two different questions; both needed, both cheap.
2. Changed-lines floor starts at 70% (not 100): the suite has pre-existing partial coverage; 100% on changed lines lands after the first ratchet pass. Global floor = whatever today measures (measured in Task 1, committed, then ratchet).
3. coverage.xml + json artifacts upload skipped (anti-complexity: the CI log is the artifact until proven needed).
## Tasks
1. **Measure + commit floors.** Run coverage locally on master; commit `.coveragerc` with the measured global floor + changed-lines floor 70; add both tools to the lock (regen via the documented command; drift ratchet verifies). RED-first test: none needed for config, but add a CI-config parse + presence test in tests/test_ci_lock_contract.py (assert ci.yml contains both floor steps — the workflow-contract pattern from #192's discharge). Commit `feat(ci): coverage floors + diff-cover ratchet (#149)`.
2. **Wire CI.** Test job gains coverage run + report + diff-cover steps (rune-ci-desktop, after the existing pytest invocation — replace bare pytest with coverage-wrapped). RED-first: extend the contract test to assert the steps exist with --fail-under flags. Commit `feat(ci): coverage ratchet in CI test job (#149)`.
3. **Verify + docs.** Local: full suite under coverage, diff-cover vs master simulated (branch diff), both floors hold; a seeded uncovered-line demo on a scratch branch fails diff-cover (the kill test — do it in the worktree, not on master). README coverage section (how to raise floors). Commit `docs(ci): coverage ratchet workflow (#149)`.
## Acceptance (from #149)
- Changed code may not reduce tested coverage → diff-cover --fail-under gate
- Critical-path coverage enforced → global floor committed, raisable
- The ratchet philosophy (only up) is structural, not aspirational
## Research trace
- Issue #149 evidence trail (posted 2026-09-07): diff-cover PyPI docs (changed-lines %, --fail-under, --compare-branch), coverage.py fail_under (global-only), CPython devguide ratchet doctrine. Kill test: seeded uncovered changed line must fail CI.
## Deviations & findings (plan tail)
1. **#150 was NOT on master 2975d76 (handoff premise wrong).** The dependency
lock + drift ratchet lived on branch `ci-dependency-lock`, not an ancestor
of master. Resolution: cherry-picked the ten #150 commits
(160681c…ada5456) onto `coverage-ratchet` as the foundation before Task 1.
All cherry-picks applied cleanly; suite green afterward (618 passed, 1
skipped, 84 subtests — the 1 skip is the live-model E2E, skipped by design
under `GITHUB_ACTIONS=true`).
2. **Coverage floor measured at 29%**, not on master but on the branch as it
will merge (master + #150 lock + #149 config): 22,246 statements, 29%
covered. `src/rune/canary_*.py` omitted from measurement (documented in
`.coveragerc` header): they are CI-run verification scripts, never
imported by tests — counting them would make the global floor a fiction.
This extends the plan's "omit tests/scripts" with canaries.
3. **diff-cover range notation: `--diff-range-notation ..` added.** Gitea
Actions checks out the PR merge ref; with the default `...` notation the
changed-lines set can span unrelated merge history. `..` pins the diff to
the merge base; `--compare-branch origin/master` is retained and
`fetch-depth: 0` (added to the unit-tests checkout — it was missing;
pinned-checkout comment preserved) guarantees the ref exists in push and
PR contexts. Chosen from `diff-cover --help`: `--diff-range-notation` is
the CLI's supported mechanism for pinning the range (`--diff-file` is the
alternative but would need a separate git step).
4. **diff-cover skips changed files with no coverage data** (observed, not
assumed): a changed file absent from coverage.xml contributes no lines to
the diff total (verified with a seeded change in an omitted canary file —
the file's executable line never entered the "Total"). Consequences:
(a) test/config/canary-only PRs trivially pass diff-cover — correct, tests
ARE the coverage; (b) changed lines outside `src/rune` (e.g. `shared/`)
are not gated — known hole for a future ratchet once `shared/` is
measured. No `--exclude` flag needed for canaries (skip ≠ 0% fail).
5. **Task 1/2 test split adjusted to keep every commit green:** Task 1's
contract test asserts the committed floors + lock pins + lock-only install
(green at commit); the ci.yml step assertions (RED-first) landed with
Task 2's wiring, per the plan's own "RED-first: extend the contract test"
wording. Contract test file: `tests/test_ci_lock_contract.py` (plan's
suggested name; it did not exist on master, created fresh — the #192
workflow-contract pattern was followed: yaml parse + structural
assertions).
6. **Kill test (Task 3, scratch branch `scratch/kill-test-149`, deleted
after evidence, nothing merged):**
- Seeded uncovered branch in `src/rune/complexity_scorer.py`
(`analyze_request_text`, 3 executable lines) on a master-based scratch
branch → `coverage run -m pytest` + `coverage xml` +
`diff-cover coverage.xml --compare-branch master --fail-under=70
--diff-range-notation ..` → `complexity_scorer.py (0.0%): Missing lines
92-94`, Total 0% → **exit 1 (CI FAILS)**. (First attempt without
`.coveragerc` on the scratch tree produced a source-less report where
the file was skipped entirely — reproduces deviation 4's skip behavior
and confirms CI must and does commit `.coveragerc`.)
- Added a test exercising the seeded branch → same pipeline →
`complexity_scorer.py (100%)`, Total 100% → **exit 0 (CI PASSES)**.
- Global-floor gate incidentally proven: on the master-based scratch tree
the suite measures 22% → `coverage report --fail-under=29` exits 2.
- Negative floor check on the real branch: `--fail-under=30` exits 2,
`--fail-under=29` exits 0 (floor is live, not decorative).
7. **Local CI simulation stand-in:** `--compare-branch gitea/master` (the
ref that actually points at 2975d76 in this worktree); in Gitea Actions
the runner's origin IS the Gitea instance, so the committed step's
`origin/master` is correct there. Simulation result on this branch:
diff-cover exits 0 ("No lines with coverage information in this diff" —
this PR touches no `src/rune` code; the ratchet arms for future PRs).
## Completion Record
- Commits (all reference #149; nothing pushed):
- Foundation: 10 cherry-picked #150 commits (dependency lock + drift
ratchet live on this branch — see deviation 1).
- `111b2bf` feat(ci): coverage floors + diff-cover ratchet (#149) —
`.coveragerc` (global floor 29, changed-lines floor 70 documented,
raise-never-lower header), pyproject dev extras += coverage/diff-cover,
lock regenerated (`coverage==7.16.0`, `diff-cover==10.5.1` + chardet,
jinja2, pluggy, pygments), `tests/test_ci_lock_contract.py` (9 tests).
- `abc4a1c` feat(ci): coverage ratchet in CI test job (#149) — unit-tests
job: `fetch-depth: 0`, `coverage run -m pytest` (bare pytest removed),
`coverage report --fail-under=29`, `coverage xml`,
`diff-cover coverage.xml --compare-branch origin/master --fail-under=70
--diff-range-notation ..`; RED-first merge-base contract test added.
- `6114294` style(ci): ruff format test_ci_lock_contract.py (#149).
- `3f358a3` docs(ci): coverage ratchet workflow (#149) — README
"Coverage ratchet" section + this plan tail.
- Measured numbers: global total 29% (22,246 statements, canaries omitted);
changed-lines floor 70 (initial, per plan design decision 2); kill test
0% FAIL → 100% PASS.
- Final battery: full suite 628 passed, 1 skipped (live-model E2E by design),
84 subtests; `tests/test_ci_lock_contract.py` 10/10;
`scripts/check_dep_lock.py` in-sync; pyright ratchet PASS (errors 117 <
baseline 120, warnings 407 < 465; new file 0 errors); ruff format + check
clean on touched .py; henxels check --all exits 0 (21 pre-existing
duplicate-similarity warnings, canary list untouched); ci.yml parses
(pyyaml) after every edit.
-47
View File
@@ -1,47 +0,0 @@
# Research round prompt template — RESEARCH ROUND V3 (4-phase)
#
# GOVERNED ARTIFACT: this file IS the research prompt consumed by the headless
# research stage (.gitea/workflows/research-stage-headless.yml). Placeholders
# {{ISSUE_NUMBER}}, {{ISSUE_TITLE}}, {{ISSUE_BODY}} are substituted at run time.
#
# SEAM (Nick, 2026-09-07): the prompt currently lives in-repo; the planned
# evolution is the Rune TUI calling back to own/generate this prompt per issue.
# Keeping it as a versioned file makes that a drop-in: the TUI writes this file
# (or a per-issue override) via PR; the stage always executes the current
# template. Prompt changes are reviewed diffs, never silent.
You are the RESEARCH ROUND agent for issue #{{ISSUE_NUMBER}}.
ISSUE TITLE: {{ISSUE_TITLE}}
ISSUE BODY:
{{ISSUE_BODY}}
Perform RESEARCH ROUND V3 in exactly four phases:
1. **INWARD** — codebase analysis. Map the relevant modules, call sites, tests,
and contracts this issue touches. Cite repo paths with file:line evidence.
2. **OUTWARD** — state-of-the-art survey. You MUST fetch live sources (curl,
arxiv, project docs) and cite real URLs you actually retrieved this run.
If a link is dead, substitute it and say so. Ask the driving question:
"is there anything BETTER than the obvious approach?"
3. **INNOVATION** — viability-filtered improvements. From the survey, list
candidate improvements, evaluate each against effort/risk/fit, and mark
which (if any) to adopt. Rejecting all candidates with reasons is a valid
outcome — honesty over novelty.
4. **SYNTHESIS** — the deliverable. End with concrete, ordered BUILD
INSTRUCTIONS: files to touch, tests to write (red-first), gates that apply
(umbrella propagation, canaries), and the PR strategy. These instructions
become the implementer's prompt — write them so an agent could execute
them without re-doing the research.
Phase headers must be exactly: INWARD, OUTWARD, INNOVATION, SYNTHESIS (each
on its own line) — the research-clear gate keys on them.
OUTPUT CONTRACT (exactly two artifacts):
1. Write the full round to research/issue-{{ISSUE_NUMBER}}-research.md
(create the directory if needed). Begin the file with:
"Research round for #{{ISSUE_NUMBER}} — {{ISSUE_TITLE}} (4-phase,
agent-generated)".
2. Print the SAME content as your final message (the workflow files it).
Do NOT commit or push anything. The workflow handles filing.
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[project.optional-dependencies]
memory = ["sqlite-vec>=0.1.0"]
dev = ["pytest>=8.0", "pytest-subtests", "ruff==0.16.5", "pyright", "henxels==0.12.0"]
dev = ["pytest>=8.0", "pytest-subtests", "ruff==0.16.5", "pyright", "henxels==0.12.0", "coverage>=7.6", "diff-cover>=9.2"]
[tool.setuptools.packages.find]
where = ["src"]
+14 -12
View File
@@ -1,14 +1,3 @@
# 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
@@ -27,6 +16,12 @@ certifi==2026.7.22
# via
# httpcore
# httpx
chardet==7.6.0
# via diff-cover
coverage==7.16.0
# via rune (pyproject.toml)
diff-cover==10.5.1
# via rune (pyproject.toml)
frozenlist==1.8.0
# via
# aiohttp
@@ -46,6 +41,8 @@ idna==3.19
# yarl
iniconfig==2.3.0
# via pytest
jinja2==3.1.6
# via diff-cover
linkify-it-py==2.2.0
# via markdown-it-py
markdown-it-py==4.2.0
@@ -53,6 +50,8 @@ markdown-it-py==4.2.0
# mdit-py-plugins
# rich
# textual
markupsafe==3.0.3
# via jinja2
mdit-py-plugins==0.6.1
# via textual
mdurl==0.1.2
@@ -68,7 +67,9 @@ packaging==26.3
platformdirs==4.11.7
# via textual
pluggy==1.6.0
# via pytest
# via
# diff-cover
# pytest
propcache==0.5.2
# via
# aiohttp
@@ -77,6 +78,7 @@ psutil==7.2.2
# via rune (pyproject.toml)
pygments==2.21.0
# via
# diff-cover
# pytest
# rich
# textual
+2 -12
View File
@@ -48,24 +48,14 @@ def _api(
body = e.read()
except urllib.error.URLError as e:
raise MergeRefused(f"forge unreachable at {path}: {e.reason}") from e
try:
parsed: Any = json.loads(body) if body else {}
except json.JSONDecodeError:
if status not in ok:
raise MergeRefused(f"{method} {path} -> {status} (non-JSON body): {body[:200]}") from None
parsed = body # 2xx but not JSON — pass through raw
parsed: Any = json.loads(body) if body else {}
if method == "GET" and status not in ok:
raise MergeRefused(f"GET {path} -> {status}: {str(parsed)[:300]}")
return status, parsed
def verify_identity(base: str, token: str) -> str:
# Defense in depth: whatever scope the caller hands us, identity is an
# INSTANCE-scoped endpoint (#214 follow-up: repo-scoped base produced
# .../repos/o/r/user -> 404 -> unhandled JSONDecodeError).
stripped = base.rstrip("/")
instance = stripped.split("/repos/")[0] if "/repos/" in stripped else base
status, user = _api(instance, token, "/user")
status, user = _api(base, token, "/user")
login = user.get("login", "?")
print(f"merge-robot: authenticated as {login} (status {status})")
return login
-169
View File
@@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""research_backfill_sweep.py — one-time research-pending backfill (#204).
The research gate (pipeline-gate.yml) applies `research-pending` only on
ready-label EVENTS. Standing backlog issues predate the gate → never labeled
→ never researched. This sweep applies the gate's OWN readiness semantics to
the whole backlog:
eligible = open issue, not blocked, not already research-pending,
no existing 4-phase research round (INWARD/OUTWARD/INNOVATION/
SYNTHESIS comment — research already done stays done),
all `Depends on: #N` closed (verified against the server;
a missing dep is UNSATISFIED, never assumed closed).
Nick's critique, structuralized: "are these issues with research already
done?" — the sweep detects existing research and skips it. Research is never
re-dispatched for completed work.
Dry-run by default. `--apply` is the explicit mutation. Idempotent: safe to
re-run; labeled/satisfied issues are skipped on the next pass.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from typing import Any
PHASES = ("INWARD", "OUTWARD", "INNOVATION", "SYNTHESIS")
DEP_RE = re.compile(r"Depends on:\s*#(\d+)", re.IGNORECASE)
class SweepError(Exception):
"""A sweep precondition or server call failed — fail loud, never guess."""
def _api(
base: str, token: str, path: str, method: str = "GET", data: Any = None, ok: tuple = (200,)
) -> tuple[int, Any]:
req = urllib.request.Request(
base + path,
data=json.dumps(data).encode() if data is not None else None,
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
body, status = r.read(), r.status
except urllib.error.HTTPError as e:
status, body = e.code, e.read()
except urllib.error.URLError as e:
raise SweepError(f"forge unreachable at {path}: {e.reason}") from e
try:
parsed: Any = json.loads(body) if body else {}
except json.JSONDecodeError:
if status not in ok:
raise SweepError(f"{method} {path} -> {status} (non-JSON): {body[:200]}") from None
parsed = body
if method == "GET" and status not in ok:
raise SweepError(f"GET {path} -> {status}: {str(parsed)[:300]}")
return status, parsed
class ForgeAdapter:
"""Thin .get()/.post() facade over _api so logic is testable."""
def __init__(self, base: str, token: str):
self.base, self.token = base, token
def get(self, path: str) -> tuple[int, Any]:
return _api(self.base, self.token, path)
def post(self, path: str, data: Any) -> tuple[int, Any]:
return _api(self.base, self.token, path, "POST", data)
def has_research_round(comments: list[dict]) -> bool:
"""True if any comment carries all four phase markers (gate's own test)."""
for c in comments:
body = c.get("body", "")
if all(re.search(rf"\b{p}\b", body, re.IGNORECASE) for p in PHASES):
return True
return False
def compute_targets(issues: list[dict], api) -> list[dict]:
"""Eligible issues for research-pending labeling.
`api` is a ForgeAdapter-like object exposing .get(path) -> (status, body).
Gate semantics: open, not blocked, not already labeled, no existing
research round, all deps server-verified closed (missing = unsatisfied).
"""
targets = []
for i in issues:
if i.get("pull_request") or i.get("state") != "open":
continue
labels = {l["name"] for l in i.get("labels", [])}
if "blocked" in labels or "research-pending" in labels:
continue
deps = [int(m) for m in DEP_RE.findall(i.get("body") or "")]
unsatisfied = False
for d in deps:
status, dep = api.get(f"/issues/{d}")
if status != 200 or dep.get("state") != "closed":
unsatisfied = True
break
if unsatisfied:
continue
_, comments = api.get(f"/issues/{i['number']}/comments")
if has_research_round(comments if isinstance(comments, list) else []):
continue
targets.append(i)
return targets
def apply(api, numbers: list[int]) -> list[int]:
"""POST research-pending for each number; returns applied list."""
for n in numbers:
api.post(f"/issues/{n}/labels", {"labels": ["research-pending"]})
return numbers
def fetch_open_issues(api) -> list[dict]:
"""Paginated open issues (PRs filtered downstream) — page cap never
silently truncates the candidate set (teach-job lesson)."""
issues: list[dict] = []
page = 1
while True:
status, batch = api.get(f"/issues?state=open&type=issues&limit=50&page={page}")
if not isinstance(batch, list):
raise SweepError(f"issues page {page} returned non-list: {str(batch)[:200]}")
issues.extend(batch)
if len(batch) < 50:
return issues
page += 1
def main_with(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Research-pending backlog sweep")
parser.add_argument("cmd", choices=["sweep"])
parser.add_argument("--base", required=True, help="repo-scoped API base …/repos/<o>/<r>")
parser.add_argument("--token", required=True)
parser.add_argument("--apply", action="store_true", help="label for real (default dry-run)")
args = parser.parse_args(argv[1:])
api = ForgeAdapter(args.base, args.token)
issues = fetch_open_issues(api)
targets = compute_targets(issues, api)
numbers = sorted(t["number"] for t in targets)
mode = "APPLY" if args.apply else "DRY-RUN"
print(f"research-sweep[{mode}]: {len(issues)} open issues, {len(numbers)} eligible")
for t in targets:
print(f" #{t['number']}: {t['title'][:70]}")
if args.apply and numbers:
apply(api, numbers)
print(f"research-sweep: labeled {len(numbers)} issue(s) research-pending")
return 0
def main() -> int:
return main_with(sys.argv)
if __name__ == "__main__":
sys.exit(main())
+163
View File
@@ -0,0 +1,163 @@
"""CI contract: coverage ratchet floors (#149) + lock wiring (#150).
The coverage ratchet has two floors and one doctrine:
* Global floor — ``.coveragerc`` ``[report] fail_under``: the total suite
coverage, measured and committed at the #149 baseline (29%). It may only
ever be RAISED (CPython devguide ratchet doctrine); this test enforces the
committed floor is at least the baseline, so a hand-edit cannot silently
lower it.
* Changed-lines floor — ``diff-cover --fail-under=70`` in the CI test job:
changed/modified lines may not reduce tested coverage (issue #149's core
requirement). The step must exist, be wired after the pytest run, and
compare against origin/master.
The dependency lock half (#150) is guarded here too because both live in the
same CI contract: the test job installs ONLY from requirements-ci.txt, and
the lint job runs the drift ratchet.
"""
from __future__ import annotations
import configparser
import re
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
CI_WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yml"
COVERAGERC = ROOT / ".coveragerc"
LOCK_FILE = ROOT / "requirements-ci.txt"
# Measured 2026-09-07 at the #149 baseline (canary_* files omitted; see
# .coveragerc header). The floor is a ratchet: never lower than this.
BASELINE_TOTAL_COVERAGE = 29
# Initial changed-lines floor from the plan (design decision 2).
BASELINE_CHANGED_LINES_FLOOR = 70
def load_ci() -> dict:
text = CI_WORKFLOW.read_text(encoding="utf-8")
data = yaml.safe_load(text)
assert isinstance(data, dict), "ci.yml did not parse to a mapping"
return data
def unit_tests_steps() -> list[dict]:
job = load_ci()["jobs"]["unit-tests"]
steps = job.get("steps") or []
assert steps, "unit-tests job has no steps"
return steps
def step_runs(steps: list[dict]) -> list[str]:
return [s.get("run") or "" for s in steps]
def test_coveragerc_exists_with_ratchet_contract():
assert COVERAGERC.is_file(), ".coveragerc (both floors live here) is missing"
parser = configparser.ConfigParser()
parser.read(COVERAGERC)
assert parser.get("run", "source") == "src/rune"
omit = parser.get("run", "omit")
assert "tests/*" in omit and "scripts/*" in omit
# Global floor ratchet: at least the measured baseline, never lower.
floor = parser.getint("report", "fail_under")
assert floor >= BASELINE_TOTAL_COVERAGE, (
f"global coverage floor {floor} is below the committed baseline "
f"{BASELINE_TOTAL_COVERAGE} — the floor only ever rises (#149)"
)
def test_changed_lines_floor_documented_in_coveragerc():
# The changed-lines floor lives in ci.yml (diff-cover flag), but the
# .coveragerc header is the single committed place that documents both
# floors and the raise-never-lower doctrine.
text = COVERAGERC.read_text(encoding="utf-8")
assert str(BASELINE_CHANGED_LINES_FLOOR) in text
assert "never lower" in text or "only ever rises" in text
def test_ci_lock_requirements_cover_both_ratchet_tools():
# #150's lock is the install path: the ratchet tools must be pinned in it,
# otherwise the CI jobs cannot run them at all.
pins = LOCK_FILE.read_text(encoding="utf-8")
assert re.search(r"^coverage==\d+", pins, re.MULTILINE), "coverage not pinned in requirements-ci.txt"
assert re.search(r"^diff-cover==\d+", pins, re.MULTILINE), "diff-cover not pinned in requirements-ci.txt"
def test_unit_tests_job_installs_from_pinned_lock():
runs = "\n".join(step_runs(unit_tests_steps()))
assert "-r requirements-ci.txt" in runs, "#150: test env must install from the pinned lock"
assert "-e . --no-deps" in runs
def test_lint_job_runs_dependency_lock_drift_ratchet():
job = load_ci()["jobs"]["lint"]
runs = "\n".join(s.get("run") or "" for s in job.get("steps") or [])
assert "scripts/check_dep_lock.py" in runs
def test_unit_tests_checkout_has_full_fetch_depth():
# diff-cover --compare-branch origin/master needs history: without
# fetch-depth: 0 the shallow checkout has no origin/master to diff against.
for step in unit_tests_steps():
if "uses" in step and str(step["uses"]).startswith("actions/checkout"):
assert step.get("with", {}).get("fetch-depth") == 0, (
"unit-tests checkout must use fetch-depth: 0 for the diff-cover compare branch"
)
return
raise AssertionError("unit-tests job has no actions/checkout step")
def test_unit_tests_wraps_pytest_with_coverage_run():
runs = step_runs(unit_tests_steps())
pytest_steps = [r for r in runs if "pytest" in r]
assert pytest_steps, "unit-tests job never runs pytest"
for r in pytest_steps:
assert "coverage run -m pytest" in r, (
"pytest must run under `coverage run -m pytest` so the XML report exists (#149)"
)
assert re.search(r"^\s*\.test-venv/bin/python -m pytest", r, re.MULTILINE) is None, (
"bare pytest invocation found — it would silently skip coverage (#149)"
)
def test_pr_context_diff_is_against_merge_base():
# Gitea Actions checks out the PR merge ref; a plain
# --compare-branch origin/master diff can span unrelated merge-commit
# lines. diff-cover's supported mechanism for an explicit range is
# --diff-range-notation: '...' (default, three-dot) vs '..' (two-dot,
# diff against the merge base). The CI step must pin the notation
# explicitly so the changed-lines set is the PR's own lines (#149).
runs = "\n".join(step_runs(unit_tests_steps()))
assert re.search(r"diff-cover coverage\.xml.*--diff-range-notation \.\.\s", runs, re.DOTALL), (
"diff-cover must pin the diff range notation explicitly so PR coverage is measured "
"against the merge base, not the merge-ref tip (#149)"
)
def test_unit_tests_publishes_global_floor_and_changed_lines_floor():
runs = "\n".join(step_runs(unit_tests_steps()))
assert re.search(r"coverage report --fail-under=\d+", runs), (
"missing `coverage report --fail-under=<floor>` step (global floor, #149)"
)
assert "coverage xml" in runs, "missing `coverage xml` step (diff-cover needs the XML report)"
assert re.search(
rf"diff-cover coverage\.xml --compare-branch origin/master --fail-under={BASELINE_CHANGED_LINES_FLOOR}\b", runs
), (
"missing `diff-cover coverage.xml --compare-branch origin/master --fail-under=70` step "
"(changed-lines floor, #149)"
)
def test_floors_never_lower_than_baseline_anywhere_in_ci():
# Belt and braces: even if the step strings drift, no CI coverage floor
# may appear with a value below the committed baselines.
runs = "\n".join(step_runs(unit_tests_steps()))
for m in re.finditer(r"--fail-under=(\d+)", runs):
value = int(m.group(1))
assert value >= min(BASELINE_TOTAL_COVERAGE, BASELINE_CHANGED_LINES_FLOOR), (
f"a CI floor dropped below baseline: --fail-under={value} (#149 ratchet)"
)
-37
View File
@@ -153,40 +153,3 @@ def test_main_exits_0_all_merged(forge, monkeypatch):
monkeypatch.setattr(mr, "scan", fake_scan)
assert mr.main_with(["merge_robot.py", "tok", "base"]) == 0
def test_verify_identity_uses_instance_base_not_repo_scoped(forge, monkeypatch):
"""RED (#214 follow-up): verify_identity received the REPO-scoped base and
built .../repos/o/r/user — a 404 with a plain-text body that crashed
json.loads before MergeRefused could fire. Identity must be verified at the
INSTANCE root: .../api/v1/user."""
recorded = []
def spy_api(base, token, path, method="GET", data=None, ok=(200,)):
recorded.append((base, path))
return 200, {"login": "conductor"}
monkeypatch.setattr(mr, "_api", spy_api)
mr.verify_identity("http://x/api/v1/repos/o/r", "tok")
assert recorded == [("http://x/api/v1", "/user")], (
"identity check must hit the instance root /user, not the repo-scoped path"
)
def test_api_handles_non_json_error_body(monkeypatch):
# NOTE: no forge fixture — it replaces _api entirely, which would make the
# urlopen patch unreachable. This test exercises the REAL _api.
"""A 404 plain-text body ('404 page not found') must raise MergeRefused,
not an unhandled JSONDecodeError."""
import urllib.error
class FakeHTTPError(urllib.error.HTTPError):
def __init__(self):
super().__init__("url", 404, "not found", hdrs=None, fp=None)
def real_url(req, timeout):
raise FakeHTTPError()
monkeypatch.setattr(mr.urllib.request, "urlopen", real_url)
with pytest.raises(mr.MergeRefused):
mr._api("http://x/api/v1", "tok", "/user")
-164
View File
@@ -1,164 +0,0 @@
"""Red-first tests for scripts/research_backfill_sweep.py.
Nick's critique, structuralized (2026-09-07):
- "is research being done or are these issues with research already done?"
→ the sweep must SKIP issues that already carry a 4-phase research round
(comment with INWARD/OUTWARD/INNOVATION/SYNTHESIS markers) — relabeling
them would re-dispatch research for work already done.
- The sweep must mirror the gate's OWN readiness semantics (pipeline-gate.yml
teach job): open, not blocked, all `Depends on: #N` closed (verified against
the server — a missing dep is UNSATISFIED, never assumed closed).
- Idempotent: issues already carrying research-pending are skipped.
- Dry-run by default; --apply is the explicit mutation.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location(
"research_backfill_sweep", ROOT / "scripts" / "research_backfill_sweep.py"
)
rs = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(rs)
def _issue(n, title="work item", labels=(), body="", closed=False, is_pr=False):
d = {
"number": n,
"title": title,
"labels": [{"name": l} for l in labels],
"body": body,
"state": "closed" if closed else "open",
}
if is_pr:
d["pull_request"] = {"url": "x"}
return d
RESEARCH_COMMENT = "## INWARD\n... ## OUTWARD\n... ## INNOVATION\n... ## SYNTHESIS\n..."
class FakeForge:
"""Simulates the SERVER contract: issues list + per-issue fetch + comments."""
def __init__(self, issues, comments=None):
self.issues = issues
self.comments = comments or {} # issue_number -> [comment bodies]
self.added_labels: list[tuple[int, str]] = []
def get(self, path):
if path == "/issues?state=open&type=issues&limit=50&page=1":
return 200, [i for i in self.issues if not i.get("pull_request")]
if path.startswith("/issues/") and path.endswith("/comments"):
n = int(path.split("/")[2])
return 200, self.comments.get(n, [])
if path.startswith("/issues/"):
n = int(path.split("/")[2])
for i in self.issues:
if i["number"] == n:
return 200, i
return 404, {}
raise AssertionError(f"unexpected GET {path}")
def post(self, path, data):
if path.startswith("/issues/") and path.endswith("/labels"):
n = int(path.split("/")[2])
for label in data.get("labels", []):
self.added_labels.append((n, label))
return 200, {}
raise AssertionError(f"unexpected POST {path}")
@pytest.fixture
def forge(monkeypatch):
f = FakeForge([])
def fake_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET":
status, body = f.get(path)
else:
status, body = f.post(path, data)
if isinstance(body, str): # non-JSON error body passthrough
raise rs.SweepError(f"{method} {path} -> {status}: {body[:120]}")
return status, body
monkeypatch.setattr(rs, "_api", fake_api)
return f
def test_skips_issue_with_existing_research_round(forge):
"""Already-researched issues are NOT relabeled (Nick: 'already done?')."""
forge.issues = [_issue(10, labels=["research-pending"])]
forge.comments = {10: [RESEARCH_COMMENT]}
targets = rs.compute_targets(forge.issues, forge)
assert targets == []
def test_labels_ready_issue_without_research(forge):
forge.issues = [_issue(11)]
targets = rs.compute_targets(forge.issues, forge)
assert [t["number"] for t in targets] == [11]
def test_skips_blocked_issues(forge):
forge.issues = [_issue(12, labels=["blocked"])]
assert rs.compute_targets(forge.issues, forge) == []
def test_skips_issues_with_open_dependency(forge):
"""Gate semantics: an open dep blocks; a MISSING dep is unsatisfied too."""
forge.issues = [
_issue(13, body="Depends on: #99"),
_issue(14, body="Depends on: #404-missing"),
]
forge.issues[1]["body"] = "Depends on: #777"
targets = rs.compute_targets(forge.issues, forge)
assert targets == []
def test_satisfied_dependency_allows_labeling(forge):
forge.issues = [_issue(15, body="Depends on: #16"), _issue(16, closed=True)]
targets = rs.compute_targets(forge.issues, forge)
assert [t["number"] for t in targets] == [15]
def test_skips_already_labeled(forge):
forge.issues = [_issue(17, labels=["research-pending"])]
assert rs.compute_targets(forge.issues, forge) == []
def test_skips_prs_and_closed(forge):
forge.issues = [_issue(18, is_pr=True), _issue(19, closed=True)]
assert rs.compute_targets(forge.issues, forge) == []
def test_apply_posts_labels(forge):
forge.issues = [_issue(20), _issue(21)]
applied = rs.apply(forge, [t["number"] for t in rs.compute_targets(forge.issues, forge)])
assert sorted(applied) == [20, 21]
assert sorted(forge.added_labels) == [(20, "research-pending"), (21, "research-pending")]
def test_main_dry_run_default_makes_no_writes(forge, monkeypatch, capsys):
forge.issues = [_issue(22)]
monkeypatch.setattr(rs.ForgeAdapter, "get", lambda self, path: forge.get(path))
monkeypatch.setattr(rs.ForgeAdapter, "post", lambda self, path, data: forge.post(path, data))
rc = rs.main_with(["research_backfill_sweep.py", "sweep", "--base", "http://x/api/v1/repos/o/r", "--token", "t"])
assert rc == 0
assert forge.added_labels == [], "dry-run (default) must not mutate"
def test_main_apply_writes(forge, monkeypatch, capsys):
forge.issues = [_issue(23)]
monkeypatch.setattr(rs.ForgeAdapter, "get", lambda self, path: forge.get(path))
monkeypatch.setattr(rs.ForgeAdapter, "post", lambda self, path, data: forge.post(path, data))
rc = rs.main_with(
["research_backfill_sweep.py", "sweep", "--base", "http://x/api/v1/repos/o/r", "--token", "t", "--apply"]
)
assert rc == 0
assert forge.added_labels == [(23, "research-pending")]