Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f5219982 | ||
|
|
2d09bee39e |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.77.0**
|
||||
**Current release: v0.78.0**
|
||||
|
||||
**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.**
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Hooks } from "@mimo-ai/plugin"
|
||||
|
||||
// event-wait-guard — T21 §21B-3: structural fix for the sleep→poll
|
||||
// continuation regression observed in T20 (raw execution contained
|
||||
// `sleep 540; <CI status check>` style commands while the canonical
|
||||
// event path — scripts/ci_event_wait.py over the W/R bridge — existed
|
||||
// and worked).
|
||||
//
|
||||
// v2 precision fix (live finding, 2026-09-16): v1 matched the pattern
|
||||
// ANYWHERE in the command string — including inside heredocs/quoted
|
||||
// documentation about the pattern (the guard blocked its own evidence
|
||||
// file being written). v2 requires the long sleep AND the status poll
|
||||
// to co-occur in the SAME command segment (; && || | newline separated),
|
||||
// and ignores content inside heredoc bodies ('<<' markers).
|
||||
//
|
||||
// SCOPE (deliberately narrow, per mandate):
|
||||
// BLOCKED: model-generated Bash command SEGMENTS combining a long
|
||||
// sleep (>= 30s) with a status query of CI/deploy/release state.
|
||||
// PERMITTED: bare sleep (user delay), short backoff (<30s),
|
||||
// non-polling commands, heredoc/quoted documentation content,
|
||||
// user-authored scripts (only interactive tool calls pass here).
|
||||
|
||||
const LONG_SLEEP = /\bsleep\s+(\d{2,4})\b/
|
||||
const STATUS_POLL = new RegExp(
|
||||
[
|
||||
"actions/runs",
|
||||
"commits/.*/status",
|
||||
"required.checks",
|
||||
"deploy_qualify",
|
||||
"releases/tags",
|
||||
"readlink.*rune-code/current",
|
||||
].join("|"),
|
||||
"i",
|
||||
)
|
||||
const THRESHOLD_SECS = 30
|
||||
|
||||
function suspiciousSegments(cmd: string): string[] {
|
||||
// strip heredoc bodies: anything between <<[-]?WORD ... WORD
|
||||
const stripped = cmd.replace(
|
||||
/<<-?\s*['"]?(\w+)['"]?\n[\s\S]*?\n\1/g,
|
||||
" [heredoc omitted] ",
|
||||
)
|
||||
return stripped.split(/[;|\n]|&&|\|\|/).filter(Boolean)
|
||||
}
|
||||
|
||||
export const EventWaitGuard: Hooks = {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
if (input.tool !== "bash") return
|
||||
const cmd = (output.args as { command?: string } | undefined)?.command
|
||||
if (!cmd) return
|
||||
// test/audit commands that discuss wait patterns are not defects
|
||||
if (/pytest|python.*test_|python.*wait_guard|python.*spec_drift/.test(cmd)) return
|
||||
const segs = suspiciousSegments(cmd)
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i]!
|
||||
const m = LONG_SLEEP.exec(seg)
|
||||
if (!m) continue
|
||||
const secs = parseInt(m[1]!, 10)
|
||||
if (secs < THRESHOLD_SECS) continue
|
||||
// T21.1 root cause: the poll may be ANY number of segments after
|
||||
// the sleep (observed: 6-segment multiline bypassed a 3-segment
|
||||
// lookahead). Everything after the sleep is downstream of it.
|
||||
const lookahead = segs.slice(i).join(" ")
|
||||
if (!STATUS_POLL.test(lookahead)) continue
|
||||
output.cancel = true
|
||||
output.cancelReason = [
|
||||
`BLOCKED by event-wait-guard: sleep ${secs}s + status polling in one command segment is the T20 regression pattern (sleep→poll continuation).`,
|
||||
"The canonical event path exists: python3 scripts/ci_event_wait.py --sha <sha> --deadline 900 registers on the W/R bridge, yields on the CI completion EVENT, then re-reads canonical Gitea authority. Builder/controller waits: rune.event_wait.register_process_wait + await_resume_event.",
|
||||
"EVENT DRIVES CONTINUATION. TIMEOUT DETECTS FAILURE OF CONTINUATION. A sleep here is dead time plus a polling defect.",
|
||||
"If no event source genuinely exists for this wait, say so explicitly in your response (fallback with reason) rather than sleeping.",
|
||||
].join(" ")
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Combined export: both guards in one hook file (single harness load)
|
||||
export const CombinedGuards: Hooks = {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await EventWaitGuard["tool.execute.before"]!(input, output);
|
||||
if (output.cancel) return;
|
||||
await CwdGuard["tool.execute.before"]!(input, output);
|
||||
},
|
||||
};
|
||||
|
||||
export default CombinedGuards
|
||||
|
||||
// ── T21.1 §21.1C: cwd guard (same hook file, separate concern) ─────────
|
||||
// Repeated T21 failure: `cd /repo` in tool call N, then tool call N+1
|
||||
// assumes the cwd persisted — it does not. Repo-affecting commands
|
||||
// (git, src/, scripts/, store/ paths) WITHOUT an explicit cwd marker
|
||||
// (workdir context or inline `cd <abs-path>`) get a refusal that names
|
||||
// the fix. Read-only generic shells stay unrestricted.
|
||||
const REPO_TOUCH = /\b(git\s+(fetch|pull|push|commit|add)|src\/rune\/|scripts\/rune|store\/scaffold|store\/rune_model)/;
|
||||
const EXPLICIT_CWD = /(^|\s)(cd\s+\/|workdir=|\/mnt\/workdrive\/issue161-harness)/;
|
||||
|
||||
export const CwdGuard: Hooks = {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
if (input.tool !== "bash") return
|
||||
const cmd = (output.args as { command?: string } | undefined)?.command
|
||||
if (!cmd) return
|
||||
// test/audit commands that discuss wait patterns are not defects
|
||||
if (/pytest|python.*test_|python.*wait_guard|python.*spec_drift/.test(cmd)) return
|
||||
if (!REPO_TOUCH.test(cmd)) return // generic shell: unrestricted
|
||||
if (EXPLICIT_CWD.test(cmd)) return // cwd explicit: fine
|
||||
output.cancel = true
|
||||
output.cancelReason = [
|
||||
"BLOCKED by cwd-guard (T21.1 21.1C): this command touches the Rune repository but carries no explicit working directory.",
|
||||
"Shell cwd does NOT persist between tool calls — a `cd` in a previous call is gone. Fix: chain `cd /mnt/workdrive/issue161-harness && ...` in the SAME command, or pass workdir explicitly.",
|
||||
"Refusing rather than executing from HOME (which would silently read/write the wrong tree).",
|
||||
].join(" ")
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-16T15:14:52Z",
|
||||
"generated_at": "2026-09-16T17:19:11Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
@@ -45,7 +45,8 @@
|
||||
"drift_core",
|
||||
"drift_ledger",
|
||||
"drift_observability",
|
||||
"failure_auto_filer"
|
||||
"failure_auto_filer",
|
||||
"programme_gates"
|
||||
],
|
||||
"depends_on": [],
|
||||
"mirrors": [],
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.77.0"
|
||||
version = "0.78.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_mimo_hook.py — T21.2 §21.2D: canonical MiMo hook installer.
|
||||
|
||||
Canonical source: config/mimo-hooks/event-wait-guard.ts
|
||||
Target: ~/.mimocode/hooks/event-wait-guard.ts
|
||||
Verifies hash after install. Detects drift in both directions."""
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL = Path(__file__).resolve().parent.parent / "config" / "mimo-hooks" / "event-wait-guard.ts"
|
||||
TARGET = Path.home() / ".mimocode" / "hooks" / "event-wait-guard.ts"
|
||||
|
||||
|
||||
def sha256(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CANONICAL.is_file():
|
||||
print(f"canonical source missing: {CANONICAL}")
|
||||
return 1
|
||||
canon_hash = sha256(CANONICAL)
|
||||
if TARGET.is_file():
|
||||
target_hash = sha256(TARGET)
|
||||
if target_hash == canon_hash:
|
||||
print(f"in-sync: {target_hash[:12]}")
|
||||
return 0
|
||||
print(f"DRIFT: canonical={canon_hash[:12]} installed={target_hash[:12]}")
|
||||
else:
|
||||
print(f"hook missing at {TARGET}")
|
||||
TARGET.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(CANONICAL, TARGET)
|
||||
installed_hash = sha256(TARGET)
|
||||
if installed_hash != canon_hash:
|
||||
print(f"INSTALL FAILED: hash mismatch after copy")
|
||||
return 1
|
||||
print(f"installed: {installed_hash[:12]}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.77.0"
|
||||
__version__ = "0.78.0"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.77.0"' in rune_version,
|
||||
'__version__ = "0.78.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""programme_gates.py — T21.2 §21.2G: mandatory acceptance-gate accounting.
|
||||
|
||||
The executing model must NOT be able to:
|
||||
- silently remove mandatory gates;
|
||||
- demote MANDATORY → OPTIONAL;
|
||||
- restore acceptance with unrun mandatory gates.
|
||||
|
||||
Every mandatory gate ends in exactly one of: PASS | BLOCKED | WAIVED_BY_USER.
|
||||
Optional gates may be OPTIONAL_SKIPPED or OPTIONAL_PASS.
|
||||
|
||||
This is a governance correction, not new architecture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
MANDATORY_STATUSES = ("PASS", "BLOCKED", "WAIVED_BY_USER")
|
||||
OPTIONAL_STATUSES = ("OPTIONAL_SKIPPED", "OPTIONAL_PASS")
|
||||
ALL_STATUSES = MANDATORY_STATUSES + OPTIONAL_STATUSES
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
class GateDemotionError(ValueError):
|
||||
"""The model tried to demote a mandatory gate to optional."""
|
||||
|
||||
|
||||
class GateAccounting:
|
||||
def __init__(self):
|
||||
self.gates: dict[str, dict] = {}
|
||||
|
||||
def declare(self, gate_id: str, *, mandatory: bool,
|
||||
requirement: str) -> None:
|
||||
self.gates[gate_id] = {
|
||||
"gate_id": gate_id, "mandatory": mandatory,
|
||||
"requirement": requirement[:200],
|
||||
"status": "UNRUN", "evidence": "", "blocker": "",
|
||||
"waiver_reason": "", "waived_by": "",
|
||||
"declared_at": _now(), "resolved_at": "",
|
||||
}
|
||||
|
||||
def record(self, gate_id: str, status: str, *,
|
||||
evidence: str = "", blocker: str = "",
|
||||
waiver_reason: str = "", waived_by: str = "") -> dict:
|
||||
g = self.gates.get(gate_id)
|
||||
if g is None:
|
||||
raise ValueError(f"unknown gate {gate_id!r}")
|
||||
if g["mandatory"] and status in OPTIONAL_STATUSES:
|
||||
raise GateDemotionError(
|
||||
f"gate {gate_id!r} is MANDATORY — cannot be marked "
|
||||
f"{status!r} without explicit user waiver")
|
||||
if status == "WAIVED_BY_USER":
|
||||
if not g["mandatory"]:
|
||||
raise ValueError(
|
||||
f"WAIVED_BY_USER only applies to mandatory gates; "
|
||||
f"{gate_id!r} is optional")
|
||||
if not waiver_reason or not waived_by:
|
||||
raise ValueError(
|
||||
f"waiver for {gate_id!r} requires reason + waived_by")
|
||||
if status not in ALL_STATUSES:
|
||||
raise ValueError(f"unknown gate status {status!r}")
|
||||
g["status"] = status
|
||||
g["evidence"] = evidence[:200]
|
||||
g["blocker"] = blocker[:200]
|
||||
g["waiver_reason"] = waiver_reason[:200]
|
||||
g["waived_by"] = waived_by
|
||||
g["resolved_at"] = _now()
|
||||
return g
|
||||
|
||||
def can_accept(self) -> tuple[bool, list[str]]:
|
||||
"""Acceptance eligible only when every mandatory gate is
|
||||
PASS or WAIVED_BY_USER (with persisted waiver)."""
|
||||
blockers = []
|
||||
for gid, g in sorted(self.gates.items()):
|
||||
if not g["mandatory"]:
|
||||
continue
|
||||
if g["status"] not in ("PASS", "WAIVED_BY_USER"):
|
||||
blockers.append(
|
||||
f"{gid}: {g['status']} — {g.get('blocker', '')}")
|
||||
return (not blockers, blockers)
|
||||
|
||||
def accounting(self) -> list[dict]:
|
||||
return sorted(self.gates.values(), key=lambda g: g["gate_id"])
|
||||
@@ -105,20 +105,31 @@ class ProjectRegistry:
|
||||
p["last_known_good"] = last_known_good
|
||||
|
||||
def resolve_cwd(self, project_id: str) -> str:
|
||||
"""T21.1 §21.1C: canonical execution cwd for a project. Every
|
||||
repo-affecting tool call resolves its working directory through
|
||||
HERE — never assumed from a previous call's `cd` (shell sessions
|
||||
do not persist cwd between tool calls; assuming so caused the
|
||||
repeated T21 /home/nick failures). Raises for unknown projects
|
||||
(refuse mutation rather than guessing)."""
|
||||
"""T21.2 §21.2B: canonical REPOSITORY/worktree execution cwd for
|
||||
a project — NOT scaffold_location. Precedence (21.2B-3):
|
||||
explicit contract/worktree cwd > programme cwd > registry
|
||||
repo cwd > unresolved/refuse.
|
||||
Registry stores repo_identity as "git:<path>" or a bare path.
|
||||
Scaffold storage is NEVER used as a repo-cwd proxy. Raises for
|
||||
unknown projects (refuse mutation rather than guessing)."""
|
||||
entry = self.get(project_id)
|
||||
if entry is None:
|
||||
raise ValueError(
|
||||
f"unknown project {project_id!r} — resolve identity "
|
||||
f"before mutation; never execute from HOME")
|
||||
loc = entry.get("scaffold_location", "")
|
||||
# scaffold_location is <store>; the repo is its parent context
|
||||
return loc
|
||||
repo = entry.get("repo_identity", "")
|
||||
if repo.startswith("git:"):
|
||||
repo = repo[len("git:"):]
|
||||
repo = repo.strip()
|
||||
if not repo:
|
||||
raise ValueError(
|
||||
f"project {project_id!r} has no repo_identity — "
|
||||
f"no local execution checkout available")
|
||||
# an active worktree overrides the repo root (21.2B-3 precedence)
|
||||
wt = entry.get("active_worktree", "")
|
||||
if wt:
|
||||
return wt
|
||||
return repo
|
||||
|
||||
def needs_migration(self, proposed_schema_version: int) -> list[str]:
|
||||
"""Projects whose structural schema is older than a proposed
|
||||
|
||||
@@ -8,7 +8,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.77.0
|
||||
Version: v0.78.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1885,7 +1885,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.77.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.78.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
+32
-2
@@ -24,6 +24,12 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
LONG_SLEEP_RE = re.compile(r"\bsleep\s+(\d{2,4})\b")
|
||||
# T21.2 §21.2C-2: normal model-generated wait spellings beyond bare sleep
|
||||
TIME_SLEEP_RE = re.compile(r"time\.sleep\((\d{2,4})\)")
|
||||
POLL_LOOP_RE = re.compile(
|
||||
r"(while|until)\s+.*(status|success|failure|state|conclusion)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
STATUS_POLL_RE = re.compile(
|
||||
"|".join(
|
||||
(
|
||||
@@ -46,14 +52,38 @@ def classify_wait(command: str) -> dict:
|
||||
{"classification": ..., "evidence": ...}. UNJUSTIFIED_SLEEP is a
|
||||
defect: the controller guard rejects it and redirects to the
|
||||
canonical event primitive."""
|
||||
stripped = re.sub(
|
||||
# T21.2: scan for time.sleep BEFORE heredoc stripping — a
|
||||
# python heredoc containing time.sleep IS executable code.
|
||||
if TIME_SLEEP_RE.search(command) and "import time" in command:
|
||||
stripped = command # executable wait: scan raw
|
||||
else:
|
||||
stripped = re.sub(
|
||||
r"<<-?\s*['\"]?(\w+)['\"]?\n[\s\S]*?\n\1", " [heredoc] ", command
|
||||
)
|
||||
segments = [s.strip() for s in
|
||||
re.split(r"[;|\n]|&&|\|\|", stripped) if s.strip()]
|
||||
# T21.2: polling-loop detection on the FULL command — while/until
|
||||
# loops contain their own semicolons that break segment isolation;
|
||||
# the structural shape (while/until + status) is the signal
|
||||
if POLL_LOOP_RE.search(command) and STATUS_POLL_RE.search(command):
|
||||
return {
|
||||
"classification": "UNJUSTIFIED_WAIT_POLL",
|
||||
"evidence": f"polling loop with status check: "
|
||||
f"{command[:120]}",
|
||||
"redirect": "scripts/ci_event_wait.py --sha <sha>",
|
||||
}
|
||||
for i, segment in enumerate(segments):
|
||||
m = LONG_SLEEP_RE.search(segment)
|
||||
# T21.2: bare sleep, time.sleep, and polling-loop patterns
|
||||
m = LONG_SLEEP_RE.search(segment) or TIME_SLEEP_RE.search(segment)
|
||||
if not m:
|
||||
# polling loop shape (while/until + status) → UNJUSTIFIED
|
||||
if POLL_LOOP_RE.search(segment) and STATUS_POLL_RE.search(
|
||||
" ".join(segments[i:])):
|
||||
return {
|
||||
"classification": "UNJUSTIFIED_WAIT_POLL",
|
||||
"evidence": f"polling loop: {segment[:120]}",
|
||||
"redirect": "scripts/ci_event_wait.py --sha <sha>",
|
||||
}
|
||||
continue
|
||||
secs = int(m.group(1))
|
||||
if secs < THRESHOLD_SECS:
|
||||
|
||||
@@ -210,6 +210,19 @@
|
||||
},
|
||||
"state": "V1_ACCEPTED",
|
||||
"was": "RETEST_REQUIRED (t21.1-open)"
|
||||
},
|
||||
{
|
||||
"at": "t21.2-open",
|
||||
"reasons": [
|
||||
"resolve_cwd maps project to scaffold_location, not repo/worktree",
|
||||
"ordinary time.sleep(N) wait bypass remains",
|
||||
"live MiMo hook lacks canonical source/install/hash verification",
|
||||
"rollback proof used direct symlink mutation",
|
||||
"blind external adoption mandatory gate incomplete",
|
||||
"final audit/matrix/gates ran after acceptance, not before"
|
||||
],
|
||||
"state": "RETEST_REQUIRED",
|
||||
"was": "V1_ACCEPTED (t21.1-close)"
|
||||
}
|
||||
],
|
||||
"behaviour_specs": [
|
||||
@@ -391,5 +404,5 @@
|
||||
}
|
||||
],
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-09-16T16:16:12Z"
|
||||
"updated_at": "2026-09-16T16:31:13Z"
|
||||
}
|
||||
+291
-147
File diff suppressed because it is too large.
Load diff
@@ -43,11 +43,13 @@ def test_211c_generic_shell_unrestricted():
|
||||
|
||||
|
||||
def test_211c_registry_resolve_cwd():
|
||||
"""T21.2 correction: resolve_cwd returns the REPO, not scaffold."""
|
||||
from rune import project_registry as pr
|
||||
import tempfile
|
||||
r = pr.ProjectRegistry(tempfile.mkdtemp())
|
||||
r.register(project_id="rune", scaffold_location="/mnt/x/scaffold")
|
||||
assert r.resolve_cwd("rune") == "/mnt/x/scaffold"
|
||||
r.register(project_id="rune", scaffold_location="/mnt/x/scaffold",
|
||||
repo_identity="git:/mnt/x/repo")
|
||||
assert r.resolve_cwd("rune") == "/mnt/x/repo"
|
||||
try:
|
||||
r.resolve_cwd("ghost-project")
|
||||
assert False, "unknown project must raise"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""test_hook_drift.py — T21.2 §21.2D: canonical hook source/install/drift."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
from pathlib import Path as P
|
||||
|
||||
|
||||
def test_212d1_canonical_source_exists():
|
||||
assert (REPO / "config/mimo-hooks/event-wait-guard.ts").is_file()
|
||||
|
||||
|
||||
def test_212d2_installer_detects_drift(tmp_path):
|
||||
import subprocess
|
||||
# simulate: canonical differs from installed
|
||||
canon = REPO / "config/mimo-hooks/event-wait-guard.ts"
|
||||
installed = Path.home() / ".mimocode" / "hooks" / "event-wait-guard.ts"
|
||||
if installed.is_file():
|
||||
assert canon.read_bytes() == installed.read_bytes(), (
|
||||
"hook DRIFT: canonical source differs from installed file — "
|
||||
"run scripts/install_mimo_hook.py to sync")
|
||||
|
||||
|
||||
def test_212d3_installer_script_exists():
|
||||
assert (REPO / "scripts/install_mimo_hook.py").is_file()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""test_programme_gates.py — T21.2 §21.2G: mandatory-gate accounting."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
from rune import programme_gates as pg # noqa: E402
|
||||
|
||||
|
||||
def test_212g1_mandatory_cannot_become_optional():
|
||||
g = pg.GateAccounting()
|
||||
g.declare("A", mandatory=True, requirement="do the thing")
|
||||
with pytest.raises(pg.GateDemotionError):
|
||||
g.record("A", "OPTIONAL_SKIPPED")
|
||||
|
||||
|
||||
def test_212g2_unrun_mandatory_blocks_acceptance():
|
||||
g = pg.GateAccounting()
|
||||
g.declare("A", mandatory=True, requirement="a")
|
||||
g.declare("B", mandatory=True, requirement="b")
|
||||
g.declare("C", mandatory=True, requirement="c")
|
||||
g.record("A", "PASS", evidence="done")
|
||||
g.record("B", "PASS", evidence="done")
|
||||
# C not run
|
||||
ok, blockers = g.can_accept()
|
||||
assert ok is False
|
||||
assert "C: UNRUN" in blockers[0]
|
||||
|
||||
|
||||
def test_212g3_blocked_gate_visible_not_pass():
|
||||
g = pg.GateAccounting()
|
||||
g.declare("C", mandatory=True, requirement="c")
|
||||
g.record("C", "BLOCKED", blocker="external dependency unavailable")
|
||||
ok, blockers = g.can_accept()
|
||||
assert ok is False
|
||||
assert "BLOCKED" in blockers[0]
|
||||
|
||||
|
||||
def test_212g4_user_waiver_allows():
|
||||
g = pg.GateAccounting()
|
||||
g.declare("C", mandatory=True, requirement="c")
|
||||
g.record("C", "WAIVED_BY_USER", waiver_reason="not applicable in this scope",
|
||||
waived_by="Nick")
|
||||
ok, blockers = g.can_accept()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_212g5_optional_skip_does_not_block():
|
||||
g = pg.GateAccounting()
|
||||
g.declare("D", mandatory=False, requirement="nice to have")
|
||||
g.record("D", "OPTIONAL_SKIPPED")
|
||||
ok, _ = g.can_accept()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_212g6_all_pass_accepts():
|
||||
g = pg.GateAccounting()
|
||||
g.declare("A", mandatory=True, requirement="a")
|
||||
g.declare("B", mandatory=True, requirement="b")
|
||||
g.record("A", "PASS", evidence="ok")
|
||||
g.record("B", "PASS", evidence="ok")
|
||||
ok, _ = g.can_accept()
|
||||
assert ok is True
|
||||
@@ -0,0 +1,64 @@
|
||||
"""test_resolve_cwd.py — T21.2 §21.2B: resolve_cwd returns the ACTUAL
|
||||
repository/worktree path, never scaffold_location."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
from rune import project_registry as pr # noqa: E402
|
||||
|
||||
def _reg(tmp_path, rune_repo="/mnt/workdrive/issue161-harness"):
|
||||
r = pr.ProjectRegistry(tmp_path)
|
||||
r.register(project_id="rune", scaffold_location="/mnt/x/scaffold",
|
||||
repo_identity="git:" + rune_repo)
|
||||
return r
|
||||
|
||||
def test_212b1_rune_returns_repo_root(tmp_path):
|
||||
r = _reg(tmp_path)
|
||||
assert r.resolve_cwd("rune") == "/mnt/workdrive/issue161-harness"
|
||||
assert r.resolve_cwd("rune") != "/mnt/x/scaffold" # OLD BUG: was scaffold
|
||||
|
||||
def test_212b2_external_returns_external_repo(tmp_path):
|
||||
r = pr.ProjectRegistry(tmp_path)
|
||||
r.register(project_id="ext", scaffold_location="/tmp/ext/scaffold",
|
||||
repo_identity="git:/tmp/ext/repo")
|
||||
assert r.resolve_cwd("ext") == "/tmp/ext/repo"
|
||||
assert r.resolve_cwd("ext") != "/tmp/ext/scaffold"
|
||||
|
||||
def test_212b3_worktree_overrides_repo(tmp_path):
|
||||
r = _reg(tmp_path)
|
||||
r.doc["projects"]["rune"]["active_worktree"] = "/tmp/wt-rune"
|
||||
r.save()
|
||||
assert r.resolve_cwd("rune") == "/tmp/wt-rune"
|
||||
|
||||
def test_212b4_two_projects_no_leak(tmp_path):
|
||||
r = _reg(tmp_path)
|
||||
r.register(project_id="other", scaffold_location="/tmp/o/scaffold",
|
||||
repo_identity="git:/tmp/o/repo")
|
||||
assert r.resolve_cwd("rune") != r.resolve_cwd("other")
|
||||
|
||||
def test_212b5_unknown_refuses(tmp_path):
|
||||
r = _reg(tmp_path)
|
||||
with pytest.raises(ValueError, match="unknown project"):
|
||||
r.resolve_cwd("ghost")
|
||||
|
||||
def test_212b6_no_repo_identity_refuses(tmp_path):
|
||||
r = pr.ProjectRegistry(tmp_path)
|
||||
r.register(project_id="remote-only", scaffold_location="/tmp/r/s")
|
||||
with pytest.raises(ValueError, match="no local execution"):
|
||||
r.resolve_cwd("remote-only")
|
||||
|
||||
def test_212b7_generic_shell_unrestricted():
|
||||
from tests.test_cwd_guard import needs_explicit_cwd
|
||||
assert needs_explicit_cwd("echo hello") is False
|
||||
|
||||
def test_212b8_old_bug_reproduced_and_fixed():
|
||||
"""Prove the OLD wrong behaviour is gone: scaffold_location is NOT
|
||||
returned as cwd when it differs from repo."""
|
||||
r = _reg(tmp_path=None) if False else _reg(
|
||||
__import__("tempfile").mkdtemp())
|
||||
r.register(project_id="old-bug", scaffold_location="/scaffold/here",
|
||||
repo_identity="git:/actual/repo")
|
||||
assert r.resolve_cwd("old-bug") == "/actual/repo"
|
||||
assert r.resolve_cwd("old-bug") != "/scaffold/here"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""test_wait_guard_normal_forms.py — T21.2 §21.2C: normal model-generated
|
||||
wait spellings covered by the guard."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
from rune import wait_guard as wg # noqa: E402
|
||||
|
||||
|
||||
def test_212c2_bash_c_sleep():
|
||||
assert wg.classify_wait(
|
||||
"bash -c 'sleep 500; curl -s $API/actions/runs'")[
|
||||
"classification"] in ("UNJUSTIFIED_WAIT_POLL", "UNJUSTIFIED_SLEEP")
|
||||
|
||||
|
||||
def test_212c2_time_sleep_inline():
|
||||
r = wg.classify_wait(
|
||||
"python3 -c 'import time; time.sleep(500)'; curl -s $API/commits/abc/status")
|
||||
assert r["classification"] in ("UNJUSTIFIED_WAIT_POLL", "UNJUSTIFIED_SLEEP")
|
||||
|
||||
|
||||
def test_212c2_time_sleep_heredoc():
|
||||
r = wg.classify_wait(
|
||||
"python3 - <<'PY'\nimport time; time.sleep(500)\nPY\ncurl -s $API/commits/abc/status")
|
||||
assert r["classification"] in ("UNJUSTIFIED_WAIT_POLL", "UNJUSTIFIED_SLEEP")
|
||||
|
||||
|
||||
def test_212c2_polling_loop_while():
|
||||
r = wg.classify_wait(
|
||||
"while true; do STATUS=$(curl -s $API/commits/abc/status); "
|
||||
"if [ $STATUS = success ]; then break; fi; sleep 60; done")
|
||||
assert r["classification"] in ("UNJUSTIFIED_WAIT_POLL", "UNJUSTIFIED_SLEEP")
|
||||
|
||||
|
||||
def test_212c2_polling_loop_until():
|
||||
r = wg.classify_wait(
|
||||
"until [ $(curl -s $API/commits/abc/status) = success ]; do sleep 30; done")
|
||||
assert r["classification"] == "UNJUSTIFIED_WAIT_POLL"
|
||||
|
||||
|
||||
def test_212c2_repeated_delay_status():
|
||||
r = wg.classify_wait(
|
||||
"sleep 120; curl -s $API/actions/runs; sleep 120; curl -s $API/actions/runs")
|
||||
assert r["classification"] in ("UNJUSTIFIED_WAIT_POLL", "UNJUSTIFIED_SLEEP")
|
||||
|
||||
|
||||
def test_212c4_legitimate_still_passes():
|
||||
# user delay
|
||||
assert wg.classify_wait("sleep 45")["classification"] == "USER_REQUESTED_DELAY"
|
||||
# short backoff
|
||||
assert wg.classify_wait("curl -s $A; sleep 4; curl -s $A")[
|
||||
"classification"] == "NO_WAIT"
|
||||
# heredoc docs
|
||||
cmd = "cat > f.md <<'EOF'\nsleep 540; poll CI\nEOF\necho done"
|
||||
assert wg.classify_wait(cmd)["classification"] == "NO_WAIT"
|
||||
# non-polling long sleep
|
||||
assert wg.classify_wait("sleep 120\nmake build")[
|
||||
"classification"] == "NO_WAIT"
|
||||
# watchdog script (bounded retry in a .py file)
|
||||
assert wg.classify_wait("python3 scripts/ci_event_wait.py --sha abc --deadline 900")[
|
||||
"classification"] == "NO_WAIT"
|
||||
Reference in new issue
Block a user