Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02d31a515 | ||
|
|
5ad28a767e | ||
|
|
7f85c4b6d2 | ||
|
|
78faab1f60 | ||
|
|
f8c12dcc8b | ||
|
|
5091d38702 | ||
|
|
807c024159 | ||
|
|
dfaa469cba | ||
|
|
4c60ab8879 | ||
|
|
1d4c185d02 | ||
|
|
0e9b48fca8 | ||
|
|
f6c09c8f48 | ||
|
|
4aa270ef02 | ||
|
|
3d7c745108 | ||
|
|
ec039e1baf | ||
|
|
e63b6dcaf1 | ||
|
|
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 |
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. -->
|
||||
-->
|
||||
@@ -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,56 +56,76 @@ 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 \
|
||||
--agent build \
|
||||
-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
|
||||
> /tmp/research_events.jsonl 2>/tmp/research_err.log || {
|
||||
echo "mimo exited nonzero — stderr:"
|
||||
cat /tmp/research_err.log | tail -10
|
||||
exit 1
|
||||
}
|
||||
echo "mimo events: $(wc -l < /tmp/research_events.jsonl) lines"
|
||||
|
||||
# Extract final assistant text (fail-loud: no swallow)
|
||||
# Extract ALL text parts (the agent may produce multiple text blocks
|
||||
# across tool-use rounds; the last contiguous block is the synthesis)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
text = ""
|
||||
texts = []
|
||||
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)
|
||||
if ev.get("type") == "text":
|
||||
t = ev.get("part", {}).get("text", "")
|
||||
if t.strip():
|
||||
texts.append(t)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not text.strip():
|
||||
raise SystemExit("research stage: agent produced NO text — infrastructure or model failure (fail-loud)")
|
||||
if not texts:
|
||||
raise SystemExit(
|
||||
f"research stage: agent produced NO text in {sum(1 for _ in open('/tmp/research_events.jsonl'))} events "
|
||||
"— infrastructure or model failure (fail-loud)"
|
||||
)
|
||||
# Join all text blocks (the agent's progressive output IS the round)
|
||||
text = "\n\n".join(texts)
|
||||
open("/tmp/research_round.md", "w").write(text)
|
||||
print(f"research text: {len(text)} chars from {len(texts)} blocks")
|
||||
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 }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.inputs.issue_number || gitea.event.issue.number }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess, urllib.request
|
||||
@@ -119,6 +148,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,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.54.1**
|
||||
**Current release: v0.54.7**
|
||||
|
||||
**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.**
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.54.1"
|
||||
version = "0.54.7"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -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.7"
|
||||
@@ -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.7"' 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.7
|
||||
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.7 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -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