Compare commits

...
Author SHA1 Message Date
conductor 8989073cad Merge pull request 'chore(release): auto-bump version to 0.54.1 (post-merge automation)' (#219) from chore/auto-bump-0.54.1 into master 2026-09-08 00:13:42 +01:00
Nick 7c265e393f merge master (required-checks dispatch fix travels with the bump branch) 2026-09-08 00:10:37 +01:00
conductor 53e6b70a22 Merge pull request 'fix(release): pyproject.toml joins the bump surfaces (wheel version lag)' (#220) from research-structuralization into master 2026-09-08 00:09:21 +01:00
Nick 3eb7ecd7cb merge master (behind-base) 2026-09-08 00:07:22 +01:00
Nick 64e7380013 fix(pipeline): required-checks also runs on workflow_dispatch — auto-bump PRs created via API get no pull_request events; without this the required context never publishes and the bump PR cannot merge (#219 observed live) 2026-09-07 23:54:54 +01:00
Nick c337681f77 fix(release): auto-bump dispatches CI itself — API-created bump PRs get no pull_request events on this Gitea (#219 stuck at zero statuses, observed live) 2026-09-07 23:42:03 +01:00
Nick 0856bee01a fix(release): pyproject.toml joins the bump surfaces — wheel version was lagging __init__ forever (0.49.0 vs 0.54.0 divergence) 2026-09-07 23:31:45 +01:00
Gitea Actions 9e2653f64d chore(release): auto-bump version to 0.54.1 (post-merge automation) 2026-09-07 23:26:18 +01:00
Gitea Actions 746c302473 chore(release): auto-bump version to 0.54.1 (post-merge automation) 2026-09-07 23:26:17 +01:00
Gitea Actions 51882ef08c chore(release): auto-bump version to 0.54.1 (post-merge automation) 2026-09-07 23:26:15 +01:00
conductor cbbb4f3ee2 Merge pull request 'fix(release): auto-version resurrected — semver logic extracted, YAML-parse-death discharged (red-first)' (#218) from research-structuralization into master 2026-09-07 23:26:01 +01:00
Nick d4283cc3d2 style: ruff format on new files (lint gate) 2026-09-07 23:24:06 +01:00
Nick 2a9d6c8bc6 test: FakeForge serves /pulls for open-PR filter (fixture completeness) 2026-09-07 23:23:53 +01:00
Nick 3065d11384 fix(release): auto-version resurrected — semver logic extracted to scripts/semver_bump.py; YAML-parse-death from #190 discharge (red-first)
Root cause of missing version bumps since 2026-09-06 15:01: #190 pasted
inline python into a double-quoted YAML scalar (unescaped quotes, column-0
lines) making auto-version.yml UNPARSEABLE. Gitea rejects the workflow
wholesale — dispatch 500, pull_request:closed triggers never fire. Six
merges tonight + all merges since #190: zero bumps.

- scripts/semver_bump.py: testable next_version() (0.x semver conventions)
- workflow repaired, parses clean
- version-behind-tag now a LOUD ::warning anomaly (#192), never silent skip
- NEW GATE: tests/test_semver_bump.py validates ALL workflow files parse —
  the missing lint that let this rot silently (7 workflows covered)
2026-09-07 23:23:24 +01:00
conductor 52b9773b6d Merge pull request 'Research structuralization: dispatched headless research stage + idempotent backlog sweep (#204 second slice)' (#217) from research-structuralization into master 2026-09-07 23:10:49 +01:00
Nick 08b0037ed4 style: ruff format sweep script (lint gate) 2026-09-07 23:08:53 +01:00
Nick db14f66bc0 feat(pipeline): research structuralization — dispatched headless research stage + idempotent backlog sweep (#204 second slice)
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 2026-09-07 22:51:55 +01:00
13 changed files with 893 additions and 28 deletions

No files matched your search

+19 -23
View File
@@ -43,30 +43,16 @@ jobs:
exit 0
fi
if [ "$LATEST" != "$RELEASE_VERSION" ]; then
echo "New version $RELEASE_VERSION (last tag v$LATEST) — bump rode the PR"
exit 0
if [ "$(printf '%s\n%s\n' "$LATEST" "$RELEASE_VERSION" | sort -V | head -1)" = "$RELEASE_VERSION" ]; then
echo "::warning::VERSION-BEHIND-TAG ANOMALY (#192): master=$RELEASE_VERSION < latest tag v$LATEST — bumping from master version; reconcile the tag lineage"
else
echo "Version $RELEASE_VERSION > last tag v$LATEST — bump rode the PR"
exit 0
fi
fi
NEW=$(python3 -c "
import os, re
version = os.environ['RELEASE_VERSION']
title = os.environ.get('PR_TITLE', '')
# Conventional-commits semver: feat! / BREAKING CHANGE -> major, feat -> minor, else patch
if re.match(r'^\s*\w+(\([^)]*\))?!:', title) or 'BREAKING CHANGE' in title:
kind = 'major'
elif re.match(r'^\s*feat(\([^)]*\))?:', title):
kind = 'minor'
else:
kind = 'patch'
major, minor, patch = version.split('.')
# 0.x semver convention: breaking changes bump the MINOR until 1.0.0
result = (
f'{major}.{int(minor)+1}.0' if kind == 'major' and int(major) == 0
else f'{int(major)+1}.0.0' if kind == 'major'
else f'{major}.{int(minor)+1}.0' if kind == 'minor'
else f'{major}.{minor}.{int(patch)+1}'
)
print(result)
")
# semver from PR title — logic lives in scripts/semver_bump.py
# (#190 lesson: inline python in YAML scalars rots unlinted)
NEW=$(python3 scripts/semver_bump.py "$RELEASE_VERSION" "$PR_TITLE")
export NEW_VERSION="$NEW"
echo "Auto-bump: $RELEASE_VERSION -> $NEW (via protected-branch-safe bump PR)"
python3 - <<'PYBUMP'
@@ -116,6 +102,9 @@ print(result)
"README.md": [
(f"v{VERSION}", f"v{NEW}"),
],
"pyproject.toml": [
(f'version = "{VERSION}"', f'version = "{NEW}"'),
],
}
for path, pairs in surfaces.items():
status, cur = api(f"/contents/{path}?ref={BRANCH}")
@@ -149,6 +138,13 @@ print(result)
head_sha = pr["head"]["sha"]
print(f" bump PR #{pr_number} head {head_sha[:12]}")
# 3b. DISPATCH CI on the bump branch — API-created PRs do not receive
# pull_request events on this Gitea (observed live: #219 sat with zero
# statuses). workflow_dispatch runs ci.yml on the branch ref; the
# required-checks statuses attach to the head sha the loop polls.
disp = api("/actions/workflows/ci.yml/dispatches", "POST", {"ref": BRANCH})
print(f" CI dispatched on {BRANCH}: {disp[0]}")
# 4. wait for the protected check on the bump PR (same gate as everything else)
deadline = time.time() + 900 # 15 min ceiling
state = None
+5 -1
View File
@@ -220,7 +220,11 @@ jobs:
bash -n scripts/gitea-backup.sh scripts/gitea-restore-verify.sh
required-checks:
if: gitea.event_name == 'pull_request'
# pull_request AND workflow_dispatch: the auto-bump path creates its PR via
# API (no pull_request events on this Gitea) and dispatches ci.yml on the
# bump branch — without dispatch, required-checks never publishes and the
# bump PR can never merge (#219, observed live).
if: gitea.event_name == 'pull_request' || gitea.event_name == 'workflow_dispatch'
runs-on: rune-ci-desktop
# #148: type-check MUST gate the protected status — a red type-check job
# previously left the protected merge gate green (dependency-chain hole).
@@ -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
+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.
+191
View File
@@ -0,0 +1,191 @@
#!/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 open_pr_issue_refs(api) -> set[int]:
"""Issue numbers referenced by OPEN PRs (closes/fixes/resolves #N in the
body) — those issues are in-flight; research is premature."""
refs: set[int] = set()
page = 1
ref_re = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.IGNORECASE)
while True:
status, batch = api.get(f"/pulls?state=open&limit=50&page={page}")
if not isinstance(batch, list):
break
for pr in batch:
for m in ref_re.finditer(pr.get("body") or ""):
refs.add(int(m.group(1)))
if len(batch) < 50:
return refs
page += 1
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),
and not referenced by an open PR (in-flight work doesn't need research).
"""
in_flight = open_pr_issue_refs(api)
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
if i["number"] in in_flight:
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())
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""semver_bump.py — compute the next version from a merged PR title.
Extracted from auto-version.yml after the inline python (pasted into a YAML
double-quoted scalar with unescaped quotes and column-0 lines) made the whole
workflow unparseable — silently disabling every post-merge version bump since
#190 (2026-09-06 15:01). Bug-fix contract: logic in a testable script, not a
heredoc nobody can lint.
Conventions (0.x semver until 1.0.0):
- breaking (feat!:/fix!:/BREAKING CHANGE) -> minor bump (major+1 . 0 . 0 only at 1.x)
- feat:/feat(scope): -> minor bump (x . minor+1 . 0)
- anything else -> patch bump
"""
from __future__ import annotations
import re
import sys
BREAKING_RE = re.compile(r"^\s*\w+(\([^)]*\))?!:")
FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?")
def next_version(version: str, title: str) -> str:
if BREAKING_RE.match(title) or "BREAKING CHANGE" in title:
kind = "major"
elif FEAT_RE.match(title):
kind = "minor"
else:
kind = "patch"
major, minor, patch = (int(x) for x in version.split("."))
if kind == "major":
return f"{major}.{minor + 1}.0" if major == 0 else f"{major + 1}.0.0"
if kind == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
def main_with(argv: list[str]) -> int:
if len(argv) != 3:
print("usage: semver_bump.py <current-version> <pr-title>", file=sys.stderr)
return 2
print(next_version(argv[1], argv[2]))
return 0
if __name__ == "__main__":
sys.exit(main_with(sys.argv))
+1 -1
View File
@@ -3,4 +3,4 @@
This is the main entry point for the Rune program.
"""
__version__ = "0.54.0"
__version__ = "0.54.1"
+1 -1
View File
@@ -252,7 +252,7 @@ def main() -> int:
)
check(
"Rune minor version advertises provisioning and restore verification",
'__version__ = "0.54.0"' in rune_version,
'__version__ = "0.54.1"' in rune_version,
failures,
)
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
+2 -2
View File
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
This is not an interface. This is how I see, speak, and interact with the world.
Version: v0.54.0
Version: v0.54.1
Features: 41 (24 original + 17 new native commands)
Usage:
@@ -1923,7 +1923,7 @@ class RuneApp(App):
# Show version info
self.chat_channel.add_message(
"system",
f"[dim]Rune v0.54.0 | Textual TUI | {len(loaded)} native modules[/dim]",
f"[dim]Rune v0.54.1 | Textual TUI | {len(loaded)} native modules[/dim]",
)
# Auto-show night mode suggestion if evening/night hours
+176
View File
@@ -0,0 +1,176 @@
"""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, open_prs=None):
self.issues = issues
self.open_prs = open_prs or []
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("/pulls?state=open"):
return 200, self.open_prs
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")]
def test_skips_issues_with_open_pr(forge, monkeypatch):
"""In-flight issues (open PR references them) are not research targets —
research is premature while implementation is already under review."""
forge.issues = [_issue(30), _issue(31)]
monkeypatch.setattr(rs, "open_pr_issue_refs", lambda api: {30})
targets = rs.compute_targets(forge.issues, forge)
assert [t["number"] for t in targets] == [31]
+81
View File
@@ -0,0 +1,81 @@
"""Red-first tests for scripts/semver_bump.py — discharge of the auto-version
outage (#190 made the workflow YAML unparseable; no bump ran since 2026-09-06).
Also freezes the #192 designed anomaly: version-behind-tag must be LOUD,
never a silent skip (the workflow's old `exit 0` on LATEST != RELEASE_VERSION
silently skipped every bump while tags ran ahead of master during testing).
"""
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("semver_bump", ROOT / "scripts" / "semver_bump.py")
sb = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(sb)
def test_fix_bumps_patch():
assert sb.next_version("0.54.0", "fix(pipeline): merge-robot auth") == "0.54.1"
def test_feat_bumps_minor():
assert sb.next_version("0.54.0", "feat(pipeline): research stage") == "0.55.0"
def test_breaking_bumps_minor_at_0x():
assert sb.next_version("0.54.0", "feat(pipeline)!: redesign gates") == "0.55.0"
def test_breaking_bumps_major_at_1x():
assert sb.next_version("1.2.3", "feat!: redesign gates") == "2.0.0"
def test_breaking_change_keyword():
assert sb.next_version("0.54.0", "chore: stuff\n\nBREAKING CHANGE: api") == "0.55.0"
def test_chore_bumps_patch():
assert sb.next_version("0.54.0", "chore(release): auto-bump") == "0.54.1"
def test_scoped_feat_with_parenthetical():
assert sb.next_version("0.54.0", "feat(memory): hippocampal index") == "0.55.0"
def test_version_behind_tag_is_anomaly_not_skip():
"""#192: master version behind latest tag = loud anomaly. The workflow
must pass the version through to the bump logic (bump from CURRENT master
version), never silently exit because tags diverged."""
# 0.49.0 on master, tags at 0.54.0: bump STILL computes from 0.49.0 ->
# the anomaly is surfaced by the loud log line + the bump PR itself.
assert sb.next_version("0.49.0", "fix: thing") == "0.49.1"
@pytest.mark.parametrize(
"workflow",
[
".gitea/workflows/auto-version.yml",
".gitea/workflows/merge-robot.yml",
".gitea/workflows/ci.yml",
".gitea/workflows/pipeline-gate.yml",
".gitea/workflows/research-stage-headless.yml",
".gitea/workflows/research-backlog-sweep.yml",
".gitea/workflows/spec-gate-headless.yml",
],
)
def test_all_workflows_are_valid_yaml(workflow):
"""The missing gate that let #190 rot silently: every workflow file on
master MUST parse. This test IS the lint gate."""
import yaml
src = (ROOT / workflow).read_text()
# Gitea parses with its own loader; 'on' must survive — use BaseLoader-free
# safe_load and accept the YAML-1.1 on→bool quirk (Gitea normalizes too).
data = yaml.safe_load(src)
assert isinstance(data, dict), f"{workflow} must parse to a mapping"
assert any(k in data for k in ("on", True)), f"{workflow} missing trigger block"