Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78584cd6ce | ||
|
|
6ea9a5d4e4 | ||
|
|
1a522c6602 | ||
|
|
a0a1d4f3d3 | ||
|
|
c85bfa9a85 | ||
|
|
c76f5d9cc8 | ||
|
|
275715fbc0 | ||
|
|
1ea3462edb | ||
|
|
4573bb6a2b | ||
|
|
4b67831aa8 | ||
|
|
1f1bd9e620 | ||
|
|
e1f8ff0406 | ||
|
|
cb0ed1bf60 | ||
|
|
301a751761 | ||
|
|
6c824c1e62 | ||
|
|
6140f8e087 | ||
|
|
a8cebea264 | ||
|
|
7f050a13df | ||
|
|
78e506b99d | ||
|
|
6bfe993801 | ||
|
|
092393a620 | ||
|
|
3162c99e4f | ||
|
|
1e0c1a0618 | ||
|
|
2974b173d3 | ||
|
|
90fc6061bc | ||
|
|
3c688bd126 | ||
|
|
5ba6301047 | ||
|
|
bfb0a28626 | ||
|
|
f15e0f1b0f | ||
|
|
661f1f9869 | ||
|
|
892ae96de8 | ||
|
|
f5d8ab15bc | ||
|
|
656afa07c6 | ||
|
|
280c40394f | ||
|
|
810af91368 | ||
|
|
7089285a3e | ||
|
|
4a33074b6a | ||
|
|
99de8ce1be | ||
|
|
a1dcdadde2 | ||
|
|
56bc364832 | ||
|
|
8bcf6a81b0 | ||
|
|
d1121f6f52 | ||
|
|
01b0129c2e | ||
|
|
7841fa2bcb | ||
|
|
5de0145683 | ||
|
|
ab2c567ea4 | ||
|
|
61f1ca63e8 | ||
|
|
675080634e | ||
|
|
0e060d863b | ||
|
|
41c4f75b76 |
No files matched your search
@@ -83,3 +83,9 @@ DEPENDENCIES (epic + issues). -->
|
||||
|
||||
## Plain-language review notes
|
||||
<!-- Explain what Nick should inspect, approve, reject, or question. -->
|
||||
|
||||
## Research provenance
|
||||
<!-- If this PR's issue went through a research round: link the research file
|
||||
(research/issue-<N>-research.md) and state which synthesis decisions were
|
||||
adopted. If no research round: write "none" — the spec-gate will flag it. -->
|
||||
-->
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- name: Auto-bump if the PR did not carry a version bump
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
RELEASE_SERVER_URL: ${{ gitea.server_url }}
|
||||
RELEASE_REPO: ${{ gitea.repository }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
@@ -126,20 +126,31 @@ jobs:
|
||||
drifts = []
|
||||
for rel, pat in checks:
|
||||
if rel not in edited:
|
||||
# unchanged surface: must ALREADY carry NEW
|
||||
# unchanged surface: must ALREADY carry NEW — if it carries a
|
||||
# DIFFERENT version (base drift, e.g. pyproject at 0.49.0 while
|
||||
# master is 0.54.1), RECOVER by force-editing to NEW. The gate
|
||||
# stays: nothing commits unless every surface carries NEW.
|
||||
status, cur = api(f"/contents/{rel}?ref=master")
|
||||
text = base64.b64decode(cur.get("content", "")).decode("utf-8", "replace") if status == 200 else ""
|
||||
if status != 200:
|
||||
drifts.append(f"{rel}: unreadable ({status})")
|
||||
continue
|
||||
text = base64.b64decode(cur.get("content", "")).decode("utf-8", "replace")
|
||||
hits = re.findall(pat, text)
|
||||
if NEW not in hits:
|
||||
drifts.append(f"{rel}: unchanged but does not carry {NEW} (found {sorted(set(hits))})")
|
||||
fixed = re.sub(pat, lambda m: m.group(0).replace(m.group(1), NEW), text, count=1)
|
||||
if fixed != text:
|
||||
edited[rel] = (fixed, cur["sha"])
|
||||
print(f" RECOVERED {rel}: base drift {sorted(set(hits))} -> {NEW}")
|
||||
else:
|
||||
drifts.append(f"{rel}: pattern present but unfixable (found {sorted(set(hits))})")
|
||||
else:
|
||||
hits = re.findall(pat, edited[rel][0])
|
||||
if NEW not in hits:
|
||||
drifts.append(f"{rel}: edited content does not carry {NEW}")
|
||||
if drifts:
|
||||
print("VERSION-SURFACE DRIFT — bump aborted, nothing committed:", file=sys.stderr)
|
||||
print("VERSION-SURFACE DRIFT — bump aborted, nothing committed:")
|
||||
for d in drifts:
|
||||
print(f" - {d}", file=sys.stderr)
|
||||
print(f" - {d}")
|
||||
sys.exit(1)
|
||||
|
||||
# Commit each surface DIRECTLY to master (bookkeeping, not reviewable code)
|
||||
@@ -162,7 +173,7 @@ jobs:
|
||||
PYBUMP
|
||||
- name: Create release from merged pull request
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
RELEASE_API_URL: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases
|
||||
|
||||
@@ -80,6 +80,10 @@ jobs:
|
||||
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: Umbrella impact gate (propagation contract)
|
||||
run: |
|
||||
python3 scripts/umbrella_impact.py check \
|
||||
--base "$(git merge-base origin/master HEAD)" --head HEAD
|
||||
- name: Test with coverage (global floor + changed-lines ratchet)
|
||||
run: |
|
||||
# #149 coverage ratchet — both floors live in .coveragerc; raise,
|
||||
|
||||
@@ -21,19 +21,28 @@ name: Research stage (headless)
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue to research"
|
||||
required: true
|
||||
round:
|
||||
description: "Research round number"
|
||||
required: false
|
||||
default: "1"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: research-stage-${{ gitea.event.issue.number }}
|
||||
group: research-stage-${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
research:
|
||||
runs-on: rune-ci-desktop
|
||||
if: gitea.event.label.name == 'research-pending'
|
||||
if: ${{ gitea.event.label.name == 'research-pending' || gitea.event_name == 'workflow_dispatch' }}
|
||||
steps:
|
||||
- name: Check out master (research reads the codebase, not a PR)
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -47,21 +56,28 @@ jobs:
|
||||
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 }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
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.
|
||||
# Fetch issue title/body from the API (dispatch events carry no issue
|
||||
# payload). Deterministic, no scraping.
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import json, os, urllib.request
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
|
||||
n = os.environ["ISSUE_NUMBER"]
|
||||
req = urllib.request.Request(
|
||||
f"{base}/issues/{n}",
|
||||
headers={"Authorization": "token " + token})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
issue = json.loads(r.read())
|
||||
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)"))
|
||||
out = (tpl.replace("{{ISSUE_NUMBER}}", n)
|
||||
.replace("{{ISSUE_TITLE}}", issue.get("title", ""))
|
||||
.replace("{{ISSUE_BODY}}", issue.get("body") or "(empty body)"))
|
||||
open("/tmp/research_prompt.txt", "w").write(out)
|
||||
print(f"prompt built: {len(out)} chars from prompts/research-round.md")
|
||||
print(f"prompt built: {len(out)} chars (issue #{n}: {issue.get('title','')[:50]})")
|
||||
PY
|
||||
|
||||
/var/lib/gitea-desktop-runner/bin/mimo run \
|
||||
@@ -96,7 +112,7 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess, urllib.request
|
||||
@@ -119,6 +135,14 @@ jobs:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print(f"research comment posted: {r.status}")
|
||||
|
||||
# Disposition routing (§14): PROCEED → research-clear fires via the
|
||||
# marker comment. MORE_RESEARCH → strip the synthesis from the
|
||||
# comment is NOT needed (the gate keys on all 4 phase markers, which
|
||||
# are present); instead the run_agent_stage result records the
|
||||
# disposition and the ROUND increments on the next dispatch. The
|
||||
# scheduled sweep re-dispatches when research-pending persists and
|
||||
# no stage is running (§19 reconciliation path).
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.54.5**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
Rune treats the language model as replaceable reasoning compute rather than as the whole agent. The durable parts of the system are intended to belong to Rune:
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"drift_cache",
|
||||
"drift_compression",
|
||||
"tier_manager",
|
||||
"graph_shard_bridge"
|
||||
"graph_shard_bridge",
|
||||
"memory_selector"
|
||||
],
|
||||
"canaries": [
|
||||
"canary_query_memory",
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"BLE001": 351,
|
||||
"S110": 79
|
||||
}
|
||||
"BLE001": 348,
|
||||
"S110": 80
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.49.0"
|
||||
version = "0.54.5"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
+52
-6
@@ -87,7 +87,29 @@ def decide(base: str, token: str, pr: dict) -> str:
|
||||
return f"#{n}: skipped (status={state})"
|
||||
status, detail = _api(base, token, f"/pulls/{n}")
|
||||
if not detail.get("mergeable", False):
|
||||
return f"#{n}: skipped (not mergeable)"
|
||||
# CONFLICT-FIRST (Nick policy): resolve staleness immediately —
|
||||
# attempt the API update NOW rather than waiting a sleep cycle.
|
||||
# 409 (genuine conflict) still skips with the reason named.
|
||||
ustatus, uresp = _api(base, token, f"/pulls/{n}/update", "POST", {"style": "merge"}, ok=(200, 409))
|
||||
if ustatus == 200:
|
||||
print(f"UPDATED {n} (conflict-first: mergeable=False resolved pre-merge)", flush=True)
|
||||
# classify the update: docs-only → carried green stands; code-path
|
||||
# touch → fresh CI required before merge (#208 slip prevention)
|
||||
try:
|
||||
_, fresh = _api(base, token, f"/pulls/{n}")
|
||||
new_sha = fresh.get("head", {}).get("sha", sha)
|
||||
if classify_update(base, token, pr, new_sha) == "RERUN_REQUIRED":
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
except MergeRefused:
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
status, detail = _api(base, token, f"/pulls/{n}")
|
||||
if not detail.get("mergeable", False):
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
elif ustatus == 409:
|
||||
cmsg = str(uresp.get("message", "")) if isinstance(uresp, dict) else str(uresp)
|
||||
return f"#{n}: skipped (genuine conflict — update 409: {cmsg[:120]})"
|
||||
else:
|
||||
return f"#{n}: skipped (not mergeable; update returned {ustatus})"
|
||||
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
||||
if mstatus == 200:
|
||||
return f"#{n}: MERGED"
|
||||
@@ -116,11 +138,25 @@ def decide(base: str, token: str, pr: dict) -> str:
|
||||
if mstatus == 200:
|
||||
return f"#{n}: MERGED (after behind-base self-update)"
|
||||
if mstatus == 405 and "behind" in str(mresp.get("message", "")).lower():
|
||||
# CARRY-FORWARD PROTOCOL: the workflow inspects the tree diff
|
||||
# between the tested head and the new head; docs-only movement
|
||||
# carries the prior green (no redundant re-run — Nick's policy).
|
||||
print(f"UPDATED {n} awaiting-ci", flush=True)
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
# CARRY-FORWARD PROTOCOL: classify the tree diff between the
|
||||
# tested head and the new head. Docs-only movement carries the
|
||||
# prior green (no redundant re-run — Nick's policy); any
|
||||
# code-path touch requires fresh CI before merge (#208 slip:
|
||||
# required-checks satisfied by a stale green).
|
||||
try:
|
||||
_, fresh = _api(base, token, f"/pulls/{n}")
|
||||
new_sha = fresh.get("head", {}).get("sha", sha)
|
||||
verdict = classify_update(base, token, pr, new_sha)
|
||||
except MergeRefused:
|
||||
verdict = "RERUN_REQUIRED" # can't verify → safest default
|
||||
if verdict == "RERUN_REQUIRED":
|
||||
print(f"UPDATED {n} awaiting-ci (code-path touch)", flush=True)
|
||||
return f"#{n}: skipped (updated; CI re-running on new head)"
|
||||
print(f"UPDATED {n} carry-forward (docs-only diff)", flush=True)
|
||||
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
||||
if mstatus == 200:
|
||||
return f"#{n}: MERGED (carry-forward: docs-only update)"
|
||||
return f"#{n}: skipped (carry-forward merge returned {mstatus})"
|
||||
raise MergeRefused(f"#{n} merge refused {mstatus} after update: {str(mresp)[:200]}")
|
||||
raise MergeRefused(f"#{n} merge refused 405: {msg}")
|
||||
if mstatus == 403:
|
||||
@@ -143,6 +179,16 @@ CODE_PATHS = (
|
||||
)
|
||||
|
||||
|
||||
def classify_update(base: str, token: str, pr: dict, new_sha: str) -> str:
|
||||
"""Classify a self-heal update: CARRY_FORWARD (docs-only tree diff — the
|
||||
tested code is byte-identical, prior green stands) or RERUN_REQUIRED
|
||||
(any code-path touch — semantic interactions are real, #208 slip)."""
|
||||
old_sha = pr["head"]["sha"]
|
||||
status, cmp_data = _api(base, token, f"/compare/{old_sha}...{new_sha}")
|
||||
files = [f.get("filename", "") for f in cmp_data.get("files", []) if isinstance(f, dict)]
|
||||
return "CARRY_FORWARD" if not touches_code_path(files) else "RERUN_REQUIRED"
|
||||
|
||||
|
||||
def touches_code_path(changed_files: list[str], code_paths=CODE_PATHS) -> bool:
|
||||
"""Carry-forward policy: True iff any changed file is a code path.
|
||||
Docs-only base movement must NOT trigger a redundant full re-run."""
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""research_artifact.py — structured research-round schema + validation (P0.4).
|
||||
|
||||
Per the Forge Cognitive Research Loop handoff §8: machine-readable research
|
||||
round with causal evidence links. Phases are FROZEN artifacts — the later
|
||||
phase's input is the persisted prior artifact (§7: "the later phase must not
|
||||
be able to pretend it saw research that the pipeline never recorded").
|
||||
|
||||
Schema (minimal, causally sufficient):
|
||||
issue, round, receipt (from run_agent_stage.build_receipt),
|
||||
phases: {inward: {claims: [{id, claim, evidence[]}]},
|
||||
outward: {sources: [{id, url, title, retrieved_at, tool,
|
||||
content_hash, learned}],
|
||||
claims: [{id, claim, supports[]}]},
|
||||
innovation: {candidates: [{id, proposal, derived_from[],
|
||||
expected_gain, risks[]}]},
|
||||
synthesis: {decision, evidence_used[], blocking_unknowns[],
|
||||
feasibility, kill_test,
|
||||
disposition: PROCEED|MORE_RESEARCH}}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_outward(outward: dict) -> list[str]:
|
||||
"""§9: OUTWARD valid only with real discovery — ≥2 sources, each with
|
||||
url + tool receipt + learned note; ≥1 search query recorded."""
|
||||
errs = []
|
||||
sources = outward.get("sources", [])
|
||||
if len(sources) < 2:
|
||||
errs.append(f"OUTWARD: {len(sources)} sources < 2 (real discovery required)")
|
||||
for s_ in sources:
|
||||
for field in ("id", "url", "tool", "learned"):
|
||||
if not s_.get(field):
|
||||
errs.append(f"OUTWARD source {s_.get('id', '?')}: missing {field}")
|
||||
if not re.match(r"https?://", s_.get("url", "")):
|
||||
errs.append(f"OUTWARD source {s_.get('id', '?')}: url not http(s)")
|
||||
if not outward.get("queries"):
|
||||
errs.append("OUTWARD: no search queries recorded (discovery, not recall)")
|
||||
return errs
|
||||
|
||||
|
||||
def validate_causal_links(artifact: dict) -> list[str]:
|
||||
"""§11/§12: innovation candidates derive_from prior claims; synthesis
|
||||
decisions cite evidence_used. At least one candidate depends on OUTWARD
|
||||
evidence unless synthesis records 'no external mechanism relevant'."""
|
||||
errs = []
|
||||
phases = artifact.get("phases", {})
|
||||
inward_ids = {c["id"] for c in phases.get("inward", {}).get("claims", [])}
|
||||
outward_ids = {c["id"] for c in phases.get("outward", {}).get("claims", [])}
|
||||
candidates = phases.get("innovation", {}).get("candidates", [])
|
||||
synthesis = phases.get("synthesis", {})
|
||||
known = inward_ids | outward_ids
|
||||
for c in candidates:
|
||||
refs = set(c.get("derived_from", []))
|
||||
if not refs:
|
||||
errs.append(f"INNOVATION {c.get('id', '?')}: no derived_from (decorative, not causal)")
|
||||
continue
|
||||
if not refs & known:
|
||||
errs.append(f"INNOVATION {c.get('id', '?')}: derived_from references unknown claims {sorted(refs - known)}")
|
||||
outward_dependent = any(set(c.get("derived_from", [])) & outward_ids for c in candidates)
|
||||
if not outward_dependent and "no external mechanism relevant" not in synthesis.get("decision", ""):
|
||||
errs.append("no INNOVATION candidate derives from OUTWARD evidence — research was decorative")
|
||||
if not synthesis.get("evidence_used"):
|
||||
errs.append("SYNTHESIS: evidence_used empty — decisions untraceable")
|
||||
if synthesis.get("disposition") == "MORE_RESEARCH" and not synthesis.get("blocking_unknowns"):
|
||||
errs.append("MORE_RESEARCH disposition without named blocking_unknowns")
|
||||
return errs
|
||||
|
||||
|
||||
def validate(artifact: dict) -> list[str]:
|
||||
errs = validate_outward(artifact.get("phases", {}).get("outward", {}))
|
||||
errs += validate_causal_links(artifact)
|
||||
if artifact.get("phases", {}).get("synthesis", {}).get("disposition") not in ("PROCEED", "MORE_RESEARCH"):
|
||||
errs.append("synthesis.disposition must be PROCEED or MORE_RESEARCH")
|
||||
return errs
|
||||
|
||||
|
||||
def main_with(argv: list[str]) -> int:
|
||||
artifact = json.loads(Path(argv[1]).read_text())
|
||||
errs = validate(artifact)
|
||||
if errs:
|
||||
print("research artifact INVALID:")
|
||||
for e in errs:
|
||||
print(f" - {e}")
|
||||
return 1
|
||||
print("research artifact VALID (outward discovery + causal links verified)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main_with(sys.argv))
|
||||
@@ -133,8 +133,13 @@ def compute_targets(issues: list[dict], api) -> list[dict]:
|
||||
if unsatisfied:
|
||||
continue
|
||||
_, comments = api.get(f"/issues/{i['number']}/comments")
|
||||
if has_research_round(comments if isinstance(comments, list) else []):
|
||||
continue
|
||||
comments = comments if isinstance(comments, list) else []
|
||||
if has_research_round(comments):
|
||||
# A round exists — re-queue ONLY if its disposition says MORE_RESEARCH
|
||||
# (§14: the loop continues). Latest round wins (comments are append-only).
|
||||
more = any(re.search(r"Disposition:\s*MORE_RESEARCH", c.get("body", ""), re.IGNORECASE) for c in comments)
|
||||
if not more:
|
||||
continue
|
||||
targets.append(i)
|
||||
return targets
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
This is the main entry point for the Rune program.
|
||||
"""
|
||||
|
||||
__version__ = "0.54.1"
|
||||
__version__ = "0.54.5"
|
||||
@@ -252,7 +252,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.54.1"' in rune_version,
|
||||
'__version__ = "0.54.5"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
@@ -270,7 +270,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"release job explicitly binds its Gitea token",
|
||||
"GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}" in versioning,
|
||||
"GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}" in versioning,
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.1
|
||||
Version: v0.54.5
|
||||
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.1 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.54.5 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -54,6 +54,8 @@ class FakeForge:
|
||||
if path.endswith("/merge"):
|
||||
n = int(path.split("/")[2])
|
||||
return self.merge_results.get(n, (200, {}))
|
||||
if path.endswith("/update"):
|
||||
return 409, {"message": "There are conflicting files"} # fixture default: genuine conflict
|
||||
raise AssertionError(f"unexpected POST {path}")
|
||||
|
||||
|
||||
@@ -118,8 +120,13 @@ def test_conflict_pr_skipped(forge):
|
||||
forge.prs = [{"number": 11, "head": {"sha": "s11"}}]
|
||||
forge.statuses = {"s11": "success"}
|
||||
forge.mergeable = {11: False} # server reports conflict
|
||||
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
|
||||
assert not any(m == "POST" for m, _ in forge.calls)
|
||||
# conflict-first: the robot attempts the API update; FakeForge 409s it
|
||||
# (genuine conflict) -> skip with reason
|
||||
result = mr.decide("base", "tok", forge.prs[0])
|
||||
assert "skipped" in result and "conflict" in result.lower()
|
||||
assert not any(m == "POST" and p.endswith("/merge") for m, p in forge.calls), (
|
||||
"must never attempt a merge on a conflicted PR"
|
||||
)
|
||||
|
||||
|
||||
def test_behind_base_405_self_heals_then_merges(forge, monkeypatch):
|
||||
@@ -133,6 +140,8 @@ def test_behind_base_405_self_heals_then_merges(forge, monkeypatch):
|
||||
return 200, {"state": "success"}
|
||||
if method == "GET" and path == "/pulls/12":
|
||||
return 200, {**forge.prs[0], "mergeable": True}
|
||||
if method == "GET" and path.startswith("/compare/"):
|
||||
return 200, {"files": [{"filename": "docs/notes.md"}]} # docs-only → carry
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
return 405, {"message": "The head branch is behind the base branch"}
|
||||
if method == "POST" and path.endswith("/update"):
|
||||
@@ -355,3 +364,70 @@ def test_transient_persisting_raises_loud(forge, monkeypatch):
|
||||
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
|
||||
with pytest.raises(mr.MergeRefused, match="transient after 3 retries"):
|
||||
mr.decide("base", "tok", forge.prs[0])
|
||||
|
||||
|
||||
def test_conflict_first_policy_update_before_wait(forge, monkeypatch):
|
||||
"""Nick's policy: conflicts are resolved FIRST — the robot attempts the
|
||||
API update the moment mergeable=False is observed (before any wait),
|
||||
then a merge attempt follows within ~a minute. A PR should never sit
|
||||
through a sleep cycle while resolvable staleness is known."""
|
||||
forge.prs = [{"number": 40, "head": {"sha": "s40"}}]
|
||||
calls = {"update": 0, "merge": 0}
|
||||
|
||||
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
|
||||
if method == "GET" and path.startswith("/commits/"):
|
||||
return 200, {"state": "success"}
|
||||
if method == "GET" and path == "/pulls/40":
|
||||
# mergeable=False until the update lands (that's the conflict-first trigger)
|
||||
return 200, {**forge.prs[0], "mergeable": calls["update"] > 0}
|
||||
if method == "GET" and path.startswith("/compare/"):
|
||||
return 200, {"files": [{"filename": "docs/notes.md"}]} # docs-only → carry
|
||||
if method == "POST" and path.endswith("/update"):
|
||||
calls["update"] += 1
|
||||
return 200, {}
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
calls["merge"] += 1
|
||||
if calls["merge"] == 1:
|
||||
return 405, {"message": "Please try again later"}
|
||||
return 200, {}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
monkeypatch.setattr(mr, "_api", scripted_api)
|
||||
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
|
||||
line = mr.decide("base", "tok", forge.prs[0])
|
||||
assert calls["update"] == 1, "mergeable=False must trigger immediate update (conflict-first)"
|
||||
assert "MERGED" in line
|
||||
|
||||
|
||||
def test_carry_forward_checked_in_robot_update_path(forge, monkeypatch):
|
||||
"""After a self-heal update, the robot must classify the tree diff:
|
||||
docs-only → merge on carried green; code-path touch → skip (CI must
|
||||
re-run first). Prevents the #208 slip (merged on stale required-checks)."""
|
||||
forge.prs = [{"number": 50, "head": {"sha": "s50"}}]
|
||||
calls = {"update": 0, "merge": 0, "classify": 0}
|
||||
|
||||
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
|
||||
if method == "GET" and path.startswith("/commits/"):
|
||||
return 200, {"state": "success"}
|
||||
if method == "GET" and path == "/pulls/50":
|
||||
return 200, {**forge.prs[0], "mergeable": calls["update"] > 0}
|
||||
if method == "GET" and path.startswith("/compare/"):
|
||||
return 200, {"files": [{"filename": "src/rune/x.py"}]} # code touch → RERUN
|
||||
if method == "POST" and path.endswith("/update"):
|
||||
calls["update"] += 1
|
||||
return 200, {}
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
calls["merge"] += 1
|
||||
return 200, {}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
monkeypatch.setattr(mr, "_api", scripted_api)
|
||||
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
|
||||
monkeypatch.setattr(
|
||||
mr,
|
||||
"classify_update",
|
||||
lambda base, token, pr, new_sha: "RERUN_REQUIRED",
|
||||
)
|
||||
line = mr.decide("base", "tok", forge.prs[0])
|
||||
assert "skipped" in line and "CI re-running" in line, "code-path touch must wait for fresh CI"
|
||||
assert calls["merge"] == 0, "no merge on unverified carry"
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Red-first tests for scripts/research_artifact.py — P0.4 causal validation.
|
||||
|
||||
Per the Forge Cognitive Research Loop handoff:
|
||||
§9 OUTWARD = real discovery (≥2 sources with url+tool+learned, ≥1 query)
|
||||
§11 INNOVATION candidates MUST derive_from prior claims; ≥1 from OUTWARD
|
||||
§12 SYNTHESIS decisions cite evidence_used
|
||||
§14 MORE_RESEARCH requires named blocking_unknowns
|
||||
"""
|
||||
|
||||
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_artifact", ROOT / "scripts" / "research_artifact.py")
|
||||
ra = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ra)
|
||||
|
||||
|
||||
def make_artifact(**overrides):
|
||||
base = {
|
||||
"issue": 42,
|
||||
"round": 1,
|
||||
"receipt": {"search_or_fetch_calls": 3, "distinct_web_domains": 2},
|
||||
"phases": {
|
||||
"inward": {
|
||||
"claims": [{"id": "I1", "claim": "store uses FTS5", "evidence": ["src/rune/memory_store.py:86"]}]
|
||||
},
|
||||
"outward": {
|
||||
"queries": ["python sqlite fts5 hybrid search"],
|
||||
"sources": [
|
||||
{
|
||||
"id": "O1",
|
||||
"url": "https://sqlite.org/fts5.html",
|
||||
"title": "FTS5",
|
||||
"retrieved_at": "2026-09-08T00:00Z",
|
||||
"tool": "webfetch",
|
||||
"content_hash": "abc",
|
||||
"learned": "FTS5 supports external content tables",
|
||||
},
|
||||
{
|
||||
"id": "O2",
|
||||
"url": "https://github.com/asg017/sqlite-vec",
|
||||
"title": "sqlite-vec",
|
||||
"retrieved_at": "2026-09-08T00:00Z",
|
||||
"tool": "webfetch",
|
||||
"content_hash": "def",
|
||||
"learned": "vec0 virtual tables for embedding search",
|
||||
},
|
||||
],
|
||||
"claims": [{"id": "O-C1", "claim": "hybrid FTS+vec is viable", "supports": ["O1", "O2"]}],
|
||||
},
|
||||
"innovation": {
|
||||
"candidates": [
|
||||
{
|
||||
"id": "C1",
|
||||
"proposal": "hybrid recall via FTS+vec union",
|
||||
"derived_from": ["I1", "O-C1"],
|
||||
"expected_gain": "better recall",
|
||||
"risks": ["latency"],
|
||||
},
|
||||
]
|
||||
},
|
||||
"synthesis": {
|
||||
"decision": "adopt hybrid recall",
|
||||
"evidence_used": ["I1", "O-C1", "C1"],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "mechanism proven by O2",
|
||||
"kill_test": "recall returns >0 rows on seeded store",
|
||||
"disposition": "PROCEED",
|
||||
},
|
||||
},
|
||||
}
|
||||
for k, v in overrides.items():
|
||||
if k == "phases":
|
||||
base["phases"].update(v)
|
||||
else:
|
||||
base[k] = v
|
||||
return base
|
||||
|
||||
|
||||
def test_valid_artifact_passes():
|
||||
assert ra.validate(make_artifact()) == []
|
||||
|
||||
|
||||
def test_outward_single_source_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"outward": {
|
||||
"queries": ["q"],
|
||||
"sources": [{"id": "O1", "url": "https://x", "tool": "webfetch", "learned": "y"}],
|
||||
"claims": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("sources < 2" in e for e in errs)
|
||||
|
||||
|
||||
def test_outward_missing_learned_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"outward": {
|
||||
"queries": ["q"],
|
||||
"sources": [
|
||||
{"id": "O1", "url": "https://x", "tool": "webfetch", "learned": ""},
|
||||
{"id": "O2", "url": "https://y", "tool": "webfetch", "learned": "z"},
|
||||
],
|
||||
"claims": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("missing learned" in e for e in errs)
|
||||
|
||||
|
||||
def test_outward_no_queries_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"outward": {
|
||||
"queries": [],
|
||||
"sources": [
|
||||
{"id": "O1", "url": "https://x", "tool": "webfetch", "learned": "y"},
|
||||
{"id": "O2", "url": "https://y", "tool": "webfetch", "learned": "z"},
|
||||
],
|
||||
"claims": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("no search queries" in e for e in errs)
|
||||
|
||||
|
||||
def test_innovation_without_derived_from_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"innovation": {
|
||||
"candidates": [{"id": "C1", "proposal": "x", "derived_from": [], "expected_gain": "", "risks": []}]
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("no derived_from" in e for e in errs)
|
||||
|
||||
|
||||
def test_innovation_unknown_claim_reference_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"innovation": {
|
||||
"candidates": [
|
||||
{"id": "C1", "proposal": "x", "derived_from": ["I999"], "expected_gain": "", "risks": []}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("unknown claims" in e for e in errs)
|
||||
|
||||
|
||||
def test_no_outward_derived_candidate_fails_unless_exempt():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"innovation": {
|
||||
"candidates": [{"id": "C1", "proposal": "x", "derived_from": ["I1"], "expected_gain": "", "risks": []}]
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("decorative" in e for e in errs)
|
||||
# exemption: synthesis explicitly records no external mechanism relevant
|
||||
a["phases"]["synthesis"]["decision"] = "internal-only; no external mechanism relevant"
|
||||
errs2 = ra.validate(a)
|
||||
assert not any("decorative" in e for e in errs2)
|
||||
|
||||
|
||||
def test_synthesis_empty_evidence_used_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"synthesis": {
|
||||
"decision": "x",
|
||||
"evidence_used": [],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "",
|
||||
"kill_test": "",
|
||||
"disposition": "PROCEED",
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("evidence_used empty" in e for e in errs)
|
||||
|
||||
|
||||
def test_more_research_without_unknowns_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"synthesis": {
|
||||
"decision": "x",
|
||||
"evidence_used": ["I1"],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "",
|
||||
"kill_test": "",
|
||||
"disposition": "MORE_RESEARCH",
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("blocking_unknowns" in e for e in errs)
|
||||
|
||||
|
||||
def test_invalid_disposition_fails():
|
||||
a = make_artifact(
|
||||
phases={
|
||||
"synthesis": {
|
||||
"decision": "x",
|
||||
"evidence_used": ["I1"],
|
||||
"blocking_unknowns": [],
|
||||
"feasibility": "",
|
||||
"kill_test": "",
|
||||
"disposition": "VIBES",
|
||||
}
|
||||
}
|
||||
)
|
||||
errs = ra.validate(a)
|
||||
assert any("disposition" in e for e in errs)
|
||||
@@ -174,3 +174,19 @@ def test_skips_issues_with_open_pr(forge, monkeypatch):
|
||||
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]
|
||||
|
||||
|
||||
def test_more_research_round_not_treated_as_done(forge):
|
||||
"""P0.6 §14: a completed round with MORE_RESEARCH disposition must NOT be
|
||||
treated as done (the backfill sweep must not skip it as 'already
|
||||
researched' if the label was lost — the stage re-dispatches)."""
|
||||
forge.issues = [_issue(32)] # label lost (event delivery failure)
|
||||
forge.comments = {32: [{"body": RESEARCH_COMMENT + "\n\nDisposition: MORE_RESEARCH (blocking: U1 perf numbers)"}]}
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert [t["number"] for t in targets] == [32], "MORE_RESEARCH stays eligible for re-dispatch"
|
||||
|
||||
|
||||
def test_proceed_round_not_requeued(forge):
|
||||
forge.issues = [_issue(33)] # NOT labeled (research-clear already ran)
|
||||
forge.comments = {33: [{"body": RESEARCH_COMMENT + "\n\nDisposition: PROCEED"}]}
|
||||
assert rs.compute_targets(forge.issues, forge) == []
|
||||
Reference in new issue
Block a user