From ced14a8f91160e7cd8120b4f9be2c0a03952a80c Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 3 Nov 2026 14:31:02 +0000 Subject: [PATCH 1/2] feat: Lane resource values, batch brief --check / plan --brief, intake header pass-through, census lane histogram Autonomous orchestrators phase 2 (#553). Co-Authored-By: Claude Opus 5.5 --- agents/conductors/batch/AGENTS.md | 5 +- agents/conductors/batch/_batch.py | 110 ++++++++- agents/conductors/batch/_brief.py | 300 ++++++++++++++++++++++++ agents/conductors/intake/_intake.py | 83 ++++++- agents/faculties/sizing/_sizing.py | 20 +- skills/batch/batch.md | 16 +- tests/test_orchestrators_lane_brief.py | 310 +++++++++++++++++++++++++ 7 files changed, 820 insertions(+), 24 deletions(-) create mode 100644 agents/conductors/batch/_brief.py create mode 100644 tests/test_orchestrators_lane_brief.py diff --git a/agents/conductors/batch/AGENTS.md b/agents/conductors/batch/AGENTS.md index 936e2421..3d8ca033 100644 --- a/agents/conductors/batch/AGENTS.md +++ b/agents/conductors/batch/AGENTS.md @@ -128,7 +128,10 @@ and from the signal the organism already uses: a remote session has no `gh` (measured; `skills/GITHUB_ACCESS.md`). No flag and no environment variable decides it — a session that could lie about where it is could plan `local-dev` work it cannot run. A session reports the other lane's ready count rather than -hiding it: *"4 local-dev task(s) are ready — run `batch plan` from the laptop."* +hiding it, by resource: *"4 laptop-lane task(s) are ready (3 ral, 1 local-dev) — run +`batch plan` from the laptop."* A task's `Lane:` is `any`, `local-dev`, or one +of the resource lanes `ral` / `gpu` / `wsl-data`, each of which implies +`local-dev` (`lane_is_local` in the sizing faculty is the one test). ## It proposes; the human launches diff --git a/agents/conductors/batch/_batch.py b/agents/conductors/batch/_batch.py index 7cb06d46..c40c893f 100644 --- a/agents/conductors/batch/_batch.py +++ b/agents/conductors/batch/_batch.py @@ -46,7 +46,7 @@ from _sizing import ( # noqa: E402 BODY_MAP_PATH, LIBRARY_REPOS, effective_autonomy, effective_consequence, effective_difficulty, effective_review_minutes, effective_unattended, - normalise_repo, parse_prompt, priority_rank, + lane_is_local, normalise_repo, parse_prompt, priority_rank, ) # `_status` (same directory) is the one definition of this vocabulary — the # batch status box on the Mind's dashboard reads a record the same way this @@ -54,6 +54,7 @@ # existing importers of `_batch` keep working. sys.path.insert(0, str(Path(__file__).resolve().parent)) from _status import PENDING_RE, RULING_WORDS, freeze_line # noqa: E402 +import _brief # noqa: E402 # One slot's worth of the human's attention, and only the DEFAULT: the human # sets it per slot with --budget, because they know whether the next one is a @@ -81,8 +82,9 @@ def detect_lane() -> str: def _lane_ok(record_lane: str, session_lane: str) -> bool: - """A `local-dev` task runs only in a `local-dev` session; `any` runs anywhere.""" - return record_lane != "local-dev" or session_lane == "local-dev" + """A laptop-lane task (`local-dev`, `ral`, `gpu`, `wsl-data`) runs only in + a `local-dev` session; `any` runs anywhere.""" + return not lane_is_local(record_lane) or session_lane == "local-dev" def grade(path: Path, mind: Path) -> dict: @@ -122,6 +124,19 @@ def _header_of(text: str, key: str) -> str: return "" +def _themes_of(text: str) -> list[str]: + """The header's `Themes:` bullets — what a brief's `scoped: tags` match.""" + out, inside = [], False + for line in text.splitlines(): + if re.match(r"^Themes:\s*$", line): + inside = True + elif inside and line.startswith("- "): + out.append(line[2:].strip()) + elif inside: + break + return out + + def _epic_of(text: str) -> str: return _header_of(text, "epic") @@ -152,6 +167,7 @@ def survey(mind: Path) -> list[dict]: g["epic"] = _epic_of(text) g["phase"] = _phase_of(text) g["done"] = _is_done(text) + g["themes"] = _themes_of(text) out.append(g) return out @@ -231,7 +247,7 @@ def plan(records: list[dict], *, budget: int = DEFAULT_REVIEW_BUDGET, session_lane: str = "web-github", awaiting_review: int = 0, cap: int = DEFAULT_BACKPRESSURE_CAP, queue: list[str] | None = None, awaiting_source: str = "given", carried: list[str] | None = None, - carried_from: str = "") -> dict: + carried_from: str = "", brief: dict | None = None) -> dict: """Compose the next batch — a BatchDecision. Every constraint states itself in `rejected`, because a planner that @@ -246,6 +262,18 @@ def plan(records: list[dict], *, budget: int = DEFAULT_REVIEW_BUDGET, """ rejected: list[tuple[str, str]] = [] queue = queue or [] + all_records = records + # Under a shift brief the brief IS the human's composition (AUTONOMY.md, + # "Shift briefs and unattended `glance`"): its named tasks outrank the + # queue, then only the backlog its dip policy covers. The brief's + # `review-minutes:` is the budget. + if brief is not None: + records, out_of_scope, named = _brief.scope(records, brief) + rejected += out_of_scope + queue = named + [q for q in queue if q not in named] + minutes = _brief._nonneg_int(brief.get("review-minutes", {}).get("value", "")) + if minutes is not None: + budget = minutes # Backpressure RAMPS; it never deadlocks. It measures review-queue DEPTH, # never timing: timing is the human's, declared as `review-at:` at dispatch. @@ -332,6 +360,9 @@ def plan(records: list[dict], *, budget: int = DEFAULT_REVIEW_BUDGET, seen_epics.add(r["epic"]) libs.update(x for x in r["repos"] if x in LIBRARY_REPOS) + other_lane = _lane_counts( + r for r in records + if r["unattended"] == "ready" and not _lane_ok(r["lane"], session_lane)) return { "dispatch": [_dispatch_payload(r) for r in members], "session_lane": session_lane, @@ -345,12 +376,25 @@ def plan(records: list[dict], *, budget: int = DEFAULT_REVIEW_BUDGET, "review_minutes_planned": spent, "members": members, "rejected": rejected, - "other_lane_ready": sum( - 1 for r in records - if r["unattended"] == "ready" and not _lane_ok(r["lane"], session_lane)), + "other_lane_ready": sum(other_lane.values()), + # Per resource, so the line names what the other machine has to offer + # (`3 ral, 1 gpu`) rather than a bare count. + "other_lane_by_lane": other_lane, + # Where the ready backlog can run, before the session's lane filters it. + "lane_counts": _lane_counts(r for r in all_records + if r["unattended"] == "ready" and not r.get("done")), + "brief": brief is not None, } +def _lane_counts(records) -> dict[str, int]: + counts: dict[str, int] = {} + for r in records: + lane = r.get("lane") or "any" + counts[lane] = counts.get(lane, 0) + 1 + return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))) + + def _dispatch_payload(r: dict) -> str: """The exact text a human pastes into one unattended session. @@ -366,6 +410,12 @@ def emit(d: dict) -> None: lane = d["session_lane"] print("== BatchDecision ==") print(f"Session lane: {lane}") + if d.get("lane_counts"): + print("Ready by lane: " + " · ".join( + f"{k} {n}" for k, n in d["lane_counts"].items())) + if d.get("brief"): + print("Brief: named tasks first, then the brief's backlog " + "policy; budget is its review-minutes") print(f"Review budget: {d['effective_budget']} of {d['review_budget']} min " f"({d['backpressure']['state']}; " f"{d['backpressure']['awaiting_review']} awaiting review " @@ -417,8 +467,10 @@ def emit(d: dict) -> None: if d["other_lane_ready"]: # Reported, never silently dropped: a task the session cannot run is # still a task the human can, from the other machine. - print(f"{d['other_lane_ready']} local-dev task(s) are ready — run " - "`batch plan` from the laptop to see them.") + by = ", ".join(f"{n} {k}" for k, n in + (d.get("other_lane_by_lane") or {}).items()) + print(f"{d['other_lane_ready']} laptop-lane task(s) are ready ({by}) — " + "run `batch plan` from the laptop to see them.") print() print(f"Not selected: {len(d['rejected'])}") counts: dict[str, int] = {} @@ -2725,7 +2777,7 @@ def organ_for(kind: str, root: Path, slot: str) -> dict: def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="batch") - ap.add_argument("verb", choices=["plan", "collect"], nargs="?", + ap.add_argument("verb", choices=["plan", "collect", "brief"], nargs="?", default="plan") ap.add_argument("--mind", type=Path, default=BODY_MAP_PATH.parent) ap.add_argument("--budget", type=int, default=DEFAULT_REVIEW_BUDGET, @@ -2736,6 +2788,13 @@ def main(argv=None) -> int: ap.add_argument("--cap", type=int, default=DEFAULT_BACKPRESSURE_CAP) ap.add_argument("--lane", default="", help="override the detected session lane") ap.add_argument("--json", action="store_true", dest="as_json") + # brief / plan + ap.add_argument("--check", default="", metavar="FILE", + help="brief: validate this shift brief against " + "PyAutoMind/batches/AGENTS.md") + ap.add_argument("--brief", default="", metavar="FILE", + help="plan: compose inside this shift brief (named tasks " + "first, then its backlog policy)") # collect only ap.add_argument("--slot", default="", help="the batch record to collect (default: the newest)") @@ -2767,12 +2826,32 @@ def main(argv=None) -> int: try: if a.verb == "collect": return _main_collect(mind, a) + if a.verb == "brief": + return _main_brief(mind, a) return _main_plan(mind, a, lane) except BatchUsageError as e: print(e, file=sys.stderr) return RC_USAGE +def _main_brief(mind: Path, a) -> int: + """`batch brief --check ` — read-only; exit 0 valid, 1 refused.""" + if not a.check: + print("batch brief: --check is the only brief verb yet " + "(dispatch and digest are phase 3)", file=sys.stderr) + return RC_USAGE + res = _brief.check(Path(a.check), mind) + if a.as_json: + print(json.dumps(res, indent=2)) + else: + print(f"== Shift brief: {a.check} — {'OK' if res['ok'] else 'REFUSED'} ==") + for e in res["errors"]: + print(f" ✗ {e}") + for w in res["warnings"]: + print(f" ! {w}") + return RC_OK if res["ok"] else RC_FINDINGS + + def _main_plan(mind: Path, a, lane: str) -> int: used = [f"--{f}" for f in COLLECT_ONLY if getattr(a, f)] if used: @@ -2790,9 +2869,18 @@ def _main_plan(mind: Path, a, lane: str) -> int: else: awaiting, source = a.awaiting_review, "--awaiting-review" carried_from, carried = previous_carried(mind) + brief = None + if a.brief: + res = _brief.check(Path(a.brief), mind) + if not res["ok"]: + print(f"batch plan: the brief {a.brief} is refused — run " + "`batch brief --check` for the reasons", file=sys.stderr) + return RC_FINDINGS + brief = _brief.parse(Path(a.brief).read_text(encoding="utf-8"))["brief"] d = plan(survey(mind), budget=a.budget, session_lane=lane, awaiting_review=awaiting, cap=a.cap, queue=read_queue(mind), - awaiting_source=source, carried=carried, carried_from=carried_from) + awaiting_source=source, carried=carried, carried_from=carried_from, + brief=brief) if a.as_json: print(json.dumps(d, indent=2)) return RC_OK diff --git a/agents/conductors/batch/_brief.py b/agents/conductors/batch/_brief.py new file mode 100644 index 00000000..38e298df --- /dev/null +++ b/agents/conductors/batch/_brief.py @@ -0,0 +1,300 @@ +"""Shift briefs — parse and validate the `brief:` block of a batch record. + +The schema is `PyAutoMind/batches/AGENTS.md` "Shift brief (2026-11-03)"; the +doctrine that makes a brief the launch act is `AUTONOMY.md` "Shift briefs and +unattended `glance`". This module only READS: `check` says whether a brief is +well-formed, and `scope` says which backlog records it admits. Nothing here +dispatches, merges or writes a file — those verbs are phase 3. + +Every error names the key it is about (`brief.lanes: ...`), because the person +fixing a brief is reading the file, not this code. +""" + +from __future__ import annotations + +import datetime as _dt +import re +from pathlib import Path + +from _sizing import ( # noqa: E402 — the caller puts the faculty on sys.path + CONSEQUENCE_TIERS, LANE_VALUES, effective_consequence, effective_difficulty, + parse_prompt, +) + +BACKLOG_POLICIES = ("none", "fill-idle", "scoped") +SCOPED_KEYS = ("epics", "tags", "tiers") + +_ITEM_RE = re.compile(r"^(\s*)-\s+(.*?)\s*$") +_KEY_RE = re.compile(r"^([a-z][a-z0-9-]*):\s*(.*)$") +# A trailing comment needs whitespace before the `#`, so `PyAutoFit#1554` and +# `#anchor` paths survive. +_COMMENT_RE = re.compile(r"\s+#\s.*$") + + +class BriefError(ValueError): + """The file is not a batch record at all (unreadable, no bullets).""" + + +# --- parse --------------------------------------------------------------------- + +def _tree(text: str) -> list[dict]: + """The record's bullets as a tree: `{"text", "children"}` per item. + + Indentation decides nesting; the first `## ` heading (the digest, appended + at collect) ends the record's terms. + """ + root: list[dict] = [] + stack: list[tuple[int, list[dict]]] = [(-1, root)] + for line in text.splitlines(): + if line.startswith("## "): + break + m = _ITEM_RE.match(line) + if not m: + continue + indent, body = len(m.group(1).expandtabs(2)), _COMMENT_RE.sub("", m.group(2)) + while stack[-1][0] >= indent: + stack.pop() + node = {"text": body.strip(), "children": []} + stack[-1][1].append(node) + stack.append((indent, node["children"])) + return root + + +def _keyed(nodes: list[dict]) -> dict[str, dict]: + """First occurrence of each `key: value` node, by key.""" + out: dict[str, dict] = {} + for n in nodes: + m = _KEY_RE.match(n["text"]) + if m and m.group(1) not in out: + out[m.group(1)] = {"value": m.group(2).strip(), "children": n["children"]} + return out + + +def parse(text: str) -> dict: + """The record's top-level keys, with the `brief:` block broken out.""" + nodes = _tree(text) + if not nodes: + raise BriefError("no `- key: value` bullets — not a batch record") + top = _keyed(nodes) + brief = top.get("brief") + return {"top": top, "brief": _keyed(brief["children"]) if brief else None} + + +def _csv(value: str) -> list[str]: + return [v for v in re.split(r"[,\s]+", value.strip()) if v] + + +def _iso(value: str): + try: + return _dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + + +def _nonneg_int(value: str): + return int(value) if re.fullmatch(r"\d+", value.strip()) else None + + +def tasks_of(brief: dict) -> list[str]: + node = brief.get("tasks") + return [c["text"] for c in node["children"]] if node else [] + + +def scoped_of(brief: dict) -> dict[str, list[str]]: + node = brief.get("scoped") + out = {} + for k, v in _keyed(node["children"]).items() if node else (): + out[k] = _csv(v["value"]) + return out + + +def excluded_of(brief: dict) -> tuple[set[str], dict[str, list[str]]]: + """Excluded prompt paths, and excluded `epics:` / `tags:` lists.""" + node = brief.get("exclude") + paths, keyed = set(), {} + for c in node["children"] if node else (): + m = _KEY_RE.match(c["text"]) + if m and m.group(1) in SCOPED_KEYS: + keyed.setdefault(m.group(1), []).extend(_csv(m.group(2))) + else: + paths.add(c["text"]) + return paths, keyed + + +def lanes_of(brief: dict) -> list[str]: + node = brief.get("lanes") + return _csv(node["value"]) if node else [] + + +# --- check --------------------------------------------------------------------- + +def _resolve(mind: Path, path: str) -> Path | None: + """A named task's prompt: where the brief says, or moved to `active/`.""" + for cand in (mind / path, mind / "active" / Path(path).name): + if cand.is_file(): + return cand + return None + + +def _tier(path: Path, mind: Path) -> str: + p = parse_prompt(path, mind) + _level, _score, factors, _derived = effective_difficulty(p) + return effective_consequence(p, factors)[0] + + +def _previous_record(mind: Path, this: Path) -> Path | None: + """The newest batch record before this one, by its date-sorted name.""" + batches = mind / "batches" + try: + here = this.resolve() + except OSError: + here = this + older = sorted(f for f in batches.glob("*.md") + if f.name != "AGENTS.md" and f.resolve() != here + and f.name < this.name) + return older[-1] if older else None + + +def check(path: Path, mind: Path) -> dict: + """Validate one brief. `errors` refuse it; `warnings` do not.""" + errors: list[str] = [] + warnings: list[str] = [] + try: + rec = parse(path.read_text(encoding="utf-8", errors="replace")) + except (OSError, BriefError) as e: + return {"ok": False, "errors": [f"{path}: {e}"], "warnings": []} + top, brief = rec["top"], rec["brief"] + + # The shift: the grant expires at review-at, so without it there is none. + disp = _iso(top["dispatched"]["value"]) if "dispatched" in top else None + if "dispatched" not in top: + errors.append("dispatched: missing") + elif disp is None: + errors.append(f"dispatched: not an ISO timestamp ({top['dispatched']['value']!r})") + if "review-at" not in top: + errors.append("review-at: missing — a brief without it grants nothing " + "(AUTONOMY.md, \"What a batch launch is\")") + else: + at = _iso(top["review-at"]["value"]) + if at is None: + errors.append(f"review-at: not an ISO timestamp ({top['review-at']['value']!r})") + elif disp is not None and at <= disp: + errors.append("review-at: not after dispatched:") + + if brief is None: + errors.append("brief: missing — this is a batch record, not a shift brief") + return {"ok": False, "errors": errors, "warnings": warnings} + + if not (brief.get("goals") and brief["goals"]["children"]): + errors.append("brief.goals: missing or empty — name at least one goal") + + backlog = brief.get("backlog", {}).get("value", "") + if backlog not in BACKLOG_POLICIES: + errors.append(f"brief.backlog: {backlog or 'missing'!s} — one of " + + " | ".join(BACKLOG_POLICIES)) + scoped = scoped_of(brief) + if backlog == "scoped" and not scoped: + errors.append("brief.scoped: missing — `backlog: scoped` needs epics, " + "tags or tiers") + for key in scoped: + if key not in SCOPED_KEYS: + errors.append(f"brief.scoped.{key}: unknown — one of " + + ", ".join(SCOPED_KEYS)) + bad_tiers = [t for t in scoped.get("tiers", []) if t not in CONSEQUENCE_TIERS] + if bad_tiers: + errors.append(f"brief.scoped.tiers: {', '.join(bad_tiers)} — tiers are " + + " | ".join(CONSEQUENCE_TIERS)) + + for key in ("review-minutes", "merge-cap", "token-budget"): + if key not in brief: + errors.append(f"brief.{key}: missing") + elif _nonneg_int(brief[key]["value"]) is None: + errors.append(f"brief.{key}: not a whole number " + f"({brief[key]['value']!r})") + + lanes = lanes_of(brief) + if not lanes: + errors.append("brief.lanes: missing — any subset of " + + " ".join(LANE_VALUES)) + bad = [ln for ln in lanes if ln not in LANE_VALUES] + if bad: + errors.append(f"brief.lanes: {', '.join(bad)} — not a lane; one of " + + " ".join(LANE_VALUES)) + if "ral" in lanes: + allow = _keyed(brief["ral-allowance"]["children"]) if "ral-allowance" in brief else {} + if not allow: + errors.append("brief.ral-allowance: missing — `lanes:` includes ral") + for key in ("packets", "partition", "cpu-hours"): + if allow and key not in allow: + errors.append(f"brief.ral-allowance.{key}: missing") + for key in ("packets", "cpu-hours"): + if key in allow and _nonneg_int(allow[key]["value"]) is None: + errors.append(f"brief.ral-allowance.{key}: not a whole number") + + # Named tasks. An unresolved path warns rather than refuses: a brief kept + # as an example outlives the prompts it names. + tiers = [] + for t in tasks_of(brief): + f = _resolve(mind, t) + if f is None: + warnings.append(f"brief.tasks: {t} resolves to no prompt in draft/ or active/") + else: + tiers.append(_tier(f, mind)) + cap = _nonneg_int(brief.get("merge-cap", {}).get("value", "")) + if cap and tiers and all(t == "judge" for t in tiers) and backlog == "none": + errors.append(f"brief.merge-cap: {cap} but every named task is judge and " + "backlog: none — nothing this shift may merge unattended; " + "set 0") + + # Calibration (batches/AGENTS.md): no new brief while the last record is + # unmeasured. Only for a record that lives among the records. + if path.parent.resolve() == (mind / "batches").resolve(): + prev = _previous_record(mind, path) + if prev is not None: + prev_top = parse(prev.read_text(encoding="utf-8", errors="replace"))["top"] + if not prev_top.get("review-minutes-actual", {}).get("value"): + errors.append(f"review-minutes-actual: missing on the previous " + f"record {prev.name} — measure it before a new brief") + + return {"ok": not errors, "errors": errors, "warnings": warnings} + + +# --- scope --------------------------------------------------------------------- + +def scope(records: list[dict], brief: dict) -> tuple[list[dict], list[tuple[str, str]], list[str]]: + """Split survey records into what the brief admits and what it does not. + + Named tasks first (returned as the order the planner must honour), then + the dip policy. Backlog the brief does not cover is never dipped. + """ + named = tasks_of(brief) + named_names = {Path(t).name for t in named} + backlog = brief.get("backlog", {}).get("value", "none") + scoped = scoped_of(brief) + ex_paths, ex_keyed = excluded_of(brief) + ex_names = {Path(p).name for p in ex_paths} + lanes = set(lanes_of(brief)) or {"any"} + + kept, rejected = [], [] + for r in records: + name = Path(r["path"]).name + themes = set(r.get("themes") or []) + if name in ex_names or r.get("epic") in ex_keyed.get("epics", []) \ + or themes & set(ex_keyed.get("tags", [])): + rejected.append((r["path"], "excluded by the brief")) + continue + if name not in named_names: + if backlog == "none": + rejected.append((r["path"], "outside the brief (backlog: none)")) + continue + if backlog == "scoped" and not ( + r.get("epic") in scoped.get("epics", []) + or themes & set(scoped.get("tags", [])) + or r.get("consequence") in scoped.get("tiers", [])): + rejected.append((r["path"], "outside the brief's scoped backlog")) + continue + if (r.get("lane") or "any") not in lanes: + rejected.append((r["path"], f"lane {r.get('lane')} not in the brief's lanes")) + continue + kept.append(r) + return kept, rejected, named diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index ee92cc5c..92a80adb 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -41,7 +41,7 @@ WORK_TYPES, HUMAN_REVIEW, MANUAL_ONLY_WORK_TYPES, LIBRARY_REPOS, WORKSPACE_REPOS, ORGANISM_REPOS, KNOWN_REPOS, RISK_KEYWORDS, AMBIGUITY_KEYWORDS, normalise_repo, norm_work_type, - declared_header, + declared_header, LANE_VALUES, declared_inline, effective_difficulty, strip_declarations, _hits, effective_consequence, effective_unattended, effective_review_minutes, policy as _sizing_policy, BODY_MAP_PATH, @@ -581,11 +581,17 @@ def analyse(text: str, source: str, themes=None): # `Type:` matches the destination folder (PyAutoMind convention). For a # low-confidence triage filing that means `Type: triage`, not the provisional # guess — the guess still rides in the IntakeDecision's `work_type` field. + # A declared lane wins; otherwise the text's resource signals. `any` is + # the default and is not written — absence already means it. + lane = header.get("lane") or infer_lane(text) header = _render_header(title, folder, target_display, repos, level, autonomy, priority, themes, consequence=consequence, witness=p["witness"], review_minutes=review_minutes, - unattended=unattended) + unattended=unattended, + passthrough=_declared_passthrough(header_lines), + lane=(lane if lane != "any" + or header.get("lane") else None)) return { "source": source, "title": title, @@ -632,9 +638,56 @@ def analyse(text: str, source: str, themes=None): } +# Optional header keys a raw input may declare that classification never +# derives, so they are carried through verbatim (PyAutoBrain#553). Dropping them +# filed an epic member as a standalone pick and dated it by file mtime. Written +# after `Status:` in `_HEADER_BLOCK_KEYS` order; `Lane:` rides with the +# review-cost keys instead, below `Unattended:`. +PASSTHROUGH_KEYS = ("filed", "epic", "phase", "blocked-by", "closes-when") +_PASSTHROUGH_RE = re.compile( + r"^(Filed|Epic|Phase|Blocked-by|Closes-when):\s*(\S.*?)\s*$", re.I) + + +def _declared_passthrough(header_lines) -> list[tuple[str, str]]: + """`(Key, value)` for each pass-through key in the input's header block. + + First occurrence wins, except `Blocked-by:` / `Closes-when:`, which a + prompt may repeat (one gate per line) and so are all kept, in order. + """ + found: dict[str, list[str]] = {} + for line in header_lines: + m = _PASSTHROUGH_RE.match(line.strip()) + if not m: + continue + key = m.group(1).lower() + if key in ("blocked-by", "closes-when") or key not in found: + found.setdefault(key, []).append(m.group(2)) + return [(k.capitalize(), v) for k in PASSTHROUGH_KEYS for v in found.get(k, [])] + + +# Resource lanes inferred from the text (2026-11-03). Order is precedence: a +# prompt that needs RAL and the GPU is a RAL task, because RAL is the scarcer +# gateway. Deliberately narrow — a loose pattern routes cloud-runnable work off +# the cloud, which is the failure the lane exists to prevent. +LANE_SIGNALS = ( + ("ral", re.compile(r"\bRAL\b|\bSLURM\b|\bsbatch\b|\bsqueue\b|euclid_jump")), + ("gpu", re.compile(r"\bCUDA\b|laptop GPU|\bGPU\b on the laptop", re.I)), + ("wsl-data", re.compile(r"/mnt/c\b|Workspace/Science\b")), +) + + +def infer_lane(text: str) -> str: + """`ral` | `gpu` | `wsl-data` when the text names that resource, else `any`.""" + for lane, pattern in LANE_SIGNALS: + if pattern.search(text): + return lane + return "any" + + def _render_header(title, work_type, target_display, repos, level, autonomy, priority, themes=None, consequence=None, witness=None, - review_minutes=None, unattended=None): + review_minutes=None, unattended=None, passthrough=(), + lane=None): lines = [f"# {title}", "", f"Type: {work_type}", f"Target: {target_display}"] if repos: lines.append("Repos:") @@ -648,6 +701,7 @@ def _render_header(title, work_type, target_display, repos, level, autonomy, lines += [f"- {t}" for t in themes] lines += [f"Difficulty: {level}", f"Autonomy: {autonomy}", f"Priority: {priority}", "Status: formalised"] + lines += [f"{k}: {v}" for k, v in passthrough] # The review-cost model rides below the difficulty block: what the work # costs the organism, then what it costs the human. `Witness:` is written # ONLY when the author supplied one — see the note in `analyse`. @@ -659,6 +713,8 @@ def _render_header(title, work_type, target_display, repos, level, autonomy, lines.append(f"Review-minutes: {review_minutes}") if unattended: lines.append(f"Unattended: {unattended}") + if lane: + lines.append(f"Lane: {lane}") return "\n".join(lines) @@ -1248,6 +1304,13 @@ def recent_events(c: dict, limit: int = RECENT_MAX) -> list: return events[:limit] +def _lane_of(header: dict) -> str: + """The prompt's lane as the planner reads it: a value the sizing faculty + does not accept counts as `any`, exactly as `parse_prompt` treats it.""" + lane = (header.get("lane") or "any").lower() + return lane if lane in LANE_VALUES else "any" + + def census(mind: Path) -> dict: """Inventory the Mind's work — filed prompts plus the registry's live rows. @@ -1304,7 +1367,7 @@ def census(mind: Path) -> dict: "unattended": header.get("unattended", "-"), # Where it can run. Absent means `any` — the common case, and a # missing lane must never be read as "nowhere". - "lane": header.get("lane", "any"), + "lane": _lane_of(header), "priority": header.get("priority", "-"), "status": header.get("status", "-"), "epic": header.get("epic", ""), @@ -1322,6 +1385,9 @@ def census(mind: Path) -> dict: hygiene.append(f"{rel} — no metadata header (pre-dates intake)") if not header.get("witness"): witness_flags.append(str(rel)) + if header.get("lane") and header["lane"].lower() not in LANE_VALUES: + hygiene.append(f"{rel} — unknown Lane: {header['lane']!r} (read " + f"as any; one of {' | '.join(LANE_VALUES)})") if stray: theme_flags.append(f"{rel} — unknown theme keyword(s): " + ", ".join(stray)) @@ -1408,6 +1474,9 @@ def _count(key): "by_target": _count("target"), "by_difficulty": _count("difficulty"), "by_priority": _count("priority"), + # Where the backlog can run (autonomous-orchestrators phase 2): the + # resource lanes are what a shift brief's `lanes:` admits. + "by_lane": _count("lane"), "records": records, # Shipped work a human asked to check — never part of `records`, so # never part of the backlog count above (see the collection loop). @@ -2028,6 +2097,8 @@ def render_dashboard(c: dict) -> str: L += ["", "", ""] L += ["## Backlog", ""] + if c.get("by_lane"): + L += ["Lanes: " + " · ".join(f"{k} {n}" for k, n in c["by_lane"].items()), ""] reviews = c.get("human_review") or [] L += ['', "
", f"Human review — {len(reviews)}", ""] @@ -2384,6 +2455,9 @@ def h2(title, src): H.append("
") H += [h2("Backlog", "draft").replace("/blob/main/draft", "/tree/main/draft")] + if c.get("by_lane"): + H.append("

Lanes: " + " · ".join( + f"{k} {n}" for k, n in c["by_lane"].items()) + "

") reviews = c.get("human_review") or [] H += ['
', f'Human review — {len(reviews)}'] @@ -2639,6 +2713,7 @@ def _fmt(counts, top=None): print(f"By target: {_fmt(c['by_target'], top=8)}") print(f"By difficulty: {_fmt(c['by_difficulty'])}") print(f"By priority: {_fmt(c['by_priority'])}") + print(f"By lane: {_fmt(c['by_lane'])}") print(f"Hygiene: {len(c['hygiene'])} prompt(s) without a metadata " "header (--json lists them)") diff --git a/agents/faculties/sizing/_sizing.py b/agents/faculties/sizing/_sizing.py index b3c18181..48359838 100755 --- a/agents/faculties/sizing/_sizing.py +++ b/agents/faculties/sizing/_sizing.py @@ -437,7 +437,18 @@ def empty_discovery_reason(mind: Path, work_type: str) -> str: # `analysis-only`) rather than a parallel cloud/laptop one — `local-dev` means # the work needs the local dataset and output trees, an SSH endpoint, or the # human at the machine. Default `any`. -LANE_VALUES = ("any", "local-dev") +# +# `ral`, `gpu` and `wsl-data` (2026-11-03, autonomous-orchestrators phase 2) +# name the resource the work needs: RAL test runs, the laptop GPU, data under +# `/mnt/c`. Each REFINES `local-dev` rather than replacing it, so every consumer +# that only asks "laptop or not?" goes through `lane_is_local` and keeps +# working. `none` is spelled `any` (`PyAutoMind/REFERENCE.md`). +LANE_VALUES = ("any", "local-dev", "ral", "gpu", "wsl-data") + + +def lane_is_local(lane) -> bool: + """True for every lane but `any` (a missing lane is `any`).""" + return (lane or "any") != "any" # Surfaces that make a change a PI's decision whatever repo it lives in: a # public API, a default value, an error contract, a science-policy call, or an @@ -499,7 +510,7 @@ def declared_header(text: str) -> dict: "declared_consequence": None, "witness": None, "witness_none_reason": None, "unknown_unattended": None, "declared_review_minutes": None, "declared_unattended": None, - "lane": None} + "lane": None, "lane_invalid": None} in_fence = False for line in text.splitlines(): if line.lstrip().startswith("```"): @@ -556,6 +567,11 @@ def declared_header(text: str) -> dict: v = value.lower() if v in LANE_VALUES and out["lane"] is None: out["lane"] = v + elif v not in LANE_VALUES and out["lane_invalid"] is None: + # Not adopted, so the prompt reads as `any` — but recorded, so + # the census can flag it. The archived spelling + # `any | local-dev` lands here: it is not a lane. + out["lane_invalid"] = value elif key == "unattended": v = _norm_level(value) if v in UNATTENDED_LEVELS and out["declared_unattended"] is None: diff --git a/skills/batch/batch.md b/skills/batch/batch.md index f1dabb69..7cfa69bf 100644 --- a/skills/batch/batch.md +++ b/skills/batch/batch.md @@ -17,7 +17,8 @@ survive into your reply because they are the point of the whole surface: - the **review-minute total** — the budget is the human's hour, not a task count; - the **other lane's ready count**, when this session cannot plan it: *"4 - local-dev tasks are ready — run this from the laptop."* + laptop-lane tasks are ready (3 ral, 1 local-dev) — run this from the + laptop."* An empty batch at the backpressure cap is a **finding, not a deadlock**: nothing in the backlog costs zero review-minutes. Say which it is. @@ -121,11 +122,14 @@ In order: collect fills it in. 5. Confirm what was dispatched, and stop. Do not follow the runs. -**Planned verbs — not code yet.** `batch brief --check` (validate a brief -against the `batches/AGENTS.md` schema) arrives in phase 2 of the -autonomous-orchestrators epic; `batch dispatch` and `batch digest`, with the -watcher, in phase 3. Until then a brief is written and checked by hand, and the -steps above are the dispatch. +**Brief verbs.** `bin/pyauto-brain batch brief --check ` validates a +brief against the `batches/AGENTS.md` schema (every error names its key; +exit 1 refuses it), and `batch plan --brief ` composes inside it: named +tasks first, then only the backlog its policy covers, against its +`review-minutes:`. Run the check before writing a brief's `review-at:` into +`batches/`. `batch dispatch` and `batch digest` are **planned — not code yet**: +they arrive with the watcher in phase 3 of the autonomous-orchestrators epic, +and until then the steps above are the dispatch. ## When they come back — collect, review, close (2026-08-31) diff --git a/tests/test_orchestrators_lane_brief.py b/tests/test_orchestrators_lane_brief.py new file mode 100644 index 00000000..39748a08 --- /dev/null +++ b/tests/test_orchestrators_lane_brief.py @@ -0,0 +1,310 @@ +"""Autonomous orchestrators phase 2 (PyAutoBrain#553): `Lane:` resource values, +the shift-brief validator, intake pass-through and the census lane histogram. + +One test group per clause of the prompt's `Witness:` — (a) lane parsing, +(b) `_lane_ok` routing, (c) `batch brief --check`, (d) intake, (e) census. +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest + +BRAIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BRAIN / "agents" / "faculties" / "sizing")) +sys.path.insert(0, str(BRAIN / "agents" / "conductors" / "intake")) +import _sizing # noqa: E402 +import _intake # noqa: E402 + +_spec = importlib.util.spec_from_file_location( + "_batch_p2", BRAIN / "agents" / "conductors" / "batch" / "_batch.py") +_batch = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_batch) +_brief = _batch._brief + +NEW_LANES = ("ral", "gpu", "wsl-data") + +# The brief the schema in PyAutoMind/batches/AGENTS.md documents, as shipped in +# PyAutoMind/batches/examples/shift_brief_example.md. Carried here because the +# Brain's CI checks out the Brain alone; `test_mind_example_validates` checks +# the real file whenever a Mind checkout is beside this one. +EXAMPLE = """\ +# Batch 2026-11-04 am +- dispatched: 2026-11-04T08:30Z +- review-at: 2026-11-04T11:30Z # stated by the human +- shift: morning +- issue: https://github.com/PyAutoLabs/PyAutoMind/issues/1 +- packet: yes +- heart-ack: + - Release YELLOW; monitoring RED +- brief: + - goals: + - 1. land the next cluster-strong-lensing slice + - tasks: + - draft/feature/autolens/example_named_task.md + - backlog: scoped + - scoped: + - epics: cluster-strong-lensing + - tiers: notify, glance + - exclude: + - epics: euclid-dr1 + - review-minutes: 45 + - lanes: any local-dev ral + - merge-cap: 3 + - token-budget: 2000000 + - ral-allowance: + - packets: 2 + - partition: ral + - cpu-hours: 64 +""" + + +def _prompt(lane_line: str) -> dict: + return _sizing.declared_header( + f"# T\n\nType: feature\nTarget: PyAutoBrain\n{lane_line}\n\nBody.\n") + + +# --- (a) lane parsing ---------------------------------------------------------- + +@pytest.mark.parametrize("lane", ("any", "local-dev") + NEW_LANES) +def test_every_lane_value_parses(lane): + assert _prompt(f"Lane: {lane}")["lane"] == lane + + +def test_archived_two_value_spelling_is_rejected_and_recorded(): + h = _prompt("Lane: any | local-dev") + assert h["lane"] is None # read as `any` by every consumer + assert h["lane_invalid"] == "any | local-dev" + + +def test_first_valid_declaration_wins(): + h = _sizing.declared_header("Type: feature\nLane: ral\nLane: gpu\n") + assert h["lane"] == "ral" + + +# --- (b) routing --------------------------------------------------------------- + +@pytest.mark.parametrize("lane", NEW_LANES + ("local-dev",)) +def test_resource_lanes_are_laptop_only(lane): + assert _sizing.lane_is_local(lane) + assert _batch._lane_ok(lane, "web-github") is False + assert _batch._lane_ok(lane, "local-dev") is True + + +def test_any_runs_anywhere(): + assert not _sizing.lane_is_local("any") and not _sizing.lane_is_local(None) + assert _batch._lane_ok("any", "web-github") + + +def _rec(path, lane="any", tier="glance", **kw): + r = {"path": path, "repos": [], "work_type": "feature", "difficulty": "small", + "score": 1, "consequence": tier, "witness": "w", "review_minutes": 3, + "unattended": "ready", "why": "", "ready_why": "", "epic": None, + "lane": lane, "priority": "normal", "blocked": False, + "autonomy": "supervised", "autonomy_cap": "supervised", + "declared_autonomy": None, "done": False, "themes": []} + r.update(kw) + return r + + +def test_plan_names_the_other_lane_resources(): + recs = [_rec("draft/a.md", "ral"), _rec("draft/b.md", "ral"), + _rec("draft/c.md", "gpu"), _rec("draft/d.md")] + d = _batch.plan(recs, session_lane="web-github") + assert d["other_lane_ready"] == 3 + assert d["other_lane_by_lane"] == {"ral": 2, "gpu": 1} + assert d["lane_counts"] == {"ral": 2, "any": 1, "gpu": 1} + + +# --- (c) brief --check --------------------------------------------------------- + +def _mind(tmp_path: Path) -> Path: + mind = tmp_path / "mind" + (mind / "draft" / "feature" / "x").mkdir(parents=True) + (mind / "batches").mkdir() + return mind + + +def _write(tmp_path: Path, text: str) -> Path: + f = tmp_path / "brief.md" + f.write_text(text, encoding="utf-8") + return f + + +def test_example_brief_is_accepted(tmp_path): + res = _brief.check(_write(tmp_path, EXAMPLE), _mind(tmp_path)) + assert res["ok"], res["errors"] + # The named task is a placeholder: a warning, never a refusal. + assert any("resolves to no prompt" in w for w in res["warnings"]) + + +def test_mind_example_validates(): + mind = Path(os.environ.get("PYAUTO_MIND") or BRAIN.parent / "PyAutoMind") + example = mind / "batches" / "examples" / "shift_brief_example.md" + if not example.is_file(): + pytest.skip("no PyAutoMind checkout beside this Brain") + res = _brief.check(example, mind) + assert res["ok"], res["errors"] + + +def test_brief_without_review_at_is_refused(tmp_path): + text = "\n".join(l for l in EXAMPLE.splitlines() if "review-at" not in l) + res = _brief.check(_write(tmp_path, text), _mind(tmp_path)) + assert not res["ok"] + assert any(e.startswith("review-at: missing") for e in res["errors"]) + + +def test_cloud_is_not_a_lane(tmp_path): + text = EXAMPLE.replace("lanes: any local-dev ral", "lanes: cloud") + res = _brief.check(_write(tmp_path, text), _mind(tmp_path)) + assert not res["ok"] + assert any(e.startswith("brief.lanes: cloud") for e in res["errors"]) + + +def test_merge_cap_refused_when_every_named_task_is_judge(tmp_path): + mind = _mind(tmp_path) + for name in ("one", "two"): # no Witness: -> judge + (mind / "draft" / "feature" / "x" / f"{name}.md").write_text( + f"# {name}\n\nType: feature\nTarget: PyAutoBrain\n\nBody.\n") + text = (EXAMPLE + .replace(" - draft/feature/autolens/example_named_task.md", + " - draft/feature/x/one.md\n - draft/feature/x/two.md") + .replace("backlog: scoped", "backlog: none")) + res = _brief.check(_write(tmp_path, text), mind) + assert not res["ok"] + assert any(e.startswith("brief.merge-cap: 3") for e in res["errors"]) + # A dip can still pick glance work, so the same cap stands under one. + res = _brief.check(_write(tmp_path, text.replace("backlog: none", + "backlog: fill-idle")), mind) + assert res["ok"], res["errors"] + # And 0 is always allowed. + res = _brief.check(_write(tmp_path, text.replace("merge-cap: 3", + "merge-cap: 0")), mind) + assert res["ok"], res["errors"] + + +def test_ral_lane_needs_an_allowance(tmp_path): + text = EXAMPLE.split(" - ral-allowance:")[0] + res = _brief.check(_write(tmp_path, text), _mind(tmp_path)) + assert any(e.startswith("brief.ral-allowance: missing") for e in res["errors"]) + + +def test_calibration_refuses_a_brief_after_an_unmeasured_record(tmp_path): + mind = _mind(tmp_path) + (mind / "batches" / "2026-11-03-pm.md").write_text( + "# Batch 2026-11-03 pm\n- dispatched: 2026-11-03T17:00Z\n") + brief = mind / "batches" / "2026-11-04-am.md" + brief.write_text(EXAMPLE) + res = _brief.check(brief, mind) + assert any(e.startswith("review-minutes-actual: missing") for e in res["errors"]) + (mind / "batches" / "2026-11-03-pm.md").write_text( + "# Batch 2026-11-03 pm\n- dispatched: 2026-11-03T17:00Z\n" + "- review-minutes-actual: 20\n") + assert _brief.check(brief, mind)["ok"] + + +def test_cli_exit_codes(tmp_path): + mind = _mind(tmp_path) + good = _write(tmp_path, EXAMPLE) + assert _batch.main(["brief", "--check", str(good), "--mind", str(mind)]) == 0 + good.write_text(EXAMPLE.replace("lanes: any local-dev ral", "lanes: cloud")) + assert _batch.main(["brief", "--check", str(good), "--mind", str(mind)]) == 1 + + +def test_plan_under_a_brief_puts_named_tasks_first_and_never_dips_outside(): + brief = _brief.parse(EXAMPLE.replace( + "draft/feature/autolens/example_named_task.md", "draft/named.md"))["brief"] + recs = [_rec("draft/cheap.md", tier="judge", epic="other-epic"), + _rec("draft/named.md", tier="judge", review_minutes=20), + _rec("draft/in_scope.md", epic="cluster-strong-lensing"), + _rec("draft/excluded.md", epic="euclid-dr1", tier="notify")] + d = _batch.plan(recs, session_lane="local-dev", brief=brief) + paths = [m["path"] for m in d["members"]] + assert paths[0] == "draft/named.md" + assert "draft/in_scope.md" in paths + reasons = dict(d["rejected"]) + assert reasons["draft/cheap.md"] == "outside the brief's scoped backlog" + assert reasons["draft/excluded.md"] == "excluded by the brief" + assert d["review_budget"] == 45 + + +# --- (d) intake ---------------------------------------------------------------- + +def test_intake_proposes_ral_from_the_text(): + d = _intake.analyse("Profile the likelihood on RAL with SLURM for @PyAutoArray.", + "test") + assert "\nLane: ral" in d["header"] + + +@pytest.mark.parametrize("text,lane", [ + ("Benchmark the CUDA path on the laptop GPU for @PyAutoArray.", "gpu"), + ("Read the dataset under /mnt/c/Users/x for @PyAutoArray.", "wsl-data"), +]) +def test_intake_proposes_gpu_and_wsl_data(text, lane): + assert f"\nLane: {lane}" in _intake.analyse(text, "test")["header"] + + +def test_intake_writes_no_lane_for_cloud_runnable_text(): + assert "Lane:" not in _intake.analyse("Tidy a docstring in @PyAutoArray.", + "test")["header"] + + +DECLARED = """\ +# Wire the watcher + +Type: feature +Target: PyAutoBrain +Epic: autonomous-orchestrators +Phase: 3 +Filed: 2026-11-03 +Blocked-by: PyAutoFit#1 +Closes-when: PyAutoFit#2 merged +Lane: local-dev + +It submits to RAL via sbatch, but the declared lane wins. +""" + + +def test_declared_optional_headers_survive_apply_unchanged(tmp_path): + mind = tmp_path / "mind" + (mind / "draft").mkdir(parents=True) + d = _intake.analyse(DECLARED, "test") + rel = _intake.write_prompt(mind, d, DECLARED, "test") + written = (mind / rel).read_text() + head = written.split("\n\n", 2)[1].splitlines() + for line in ("Epic: autonomous-orchestrators", "Phase: 3", "Filed: 2026-11-03", + "Blocked-by: PyAutoFit#1", "Closes-when: PyAutoFit#2 merged", + "Lane: local-dev"): + assert line in head, line + # Documented order: pass-through keys after Status:, Lane: after Unattended:. + assert head.index("Status: formalised") < head.index("Filed: 2026-11-03") \ + < head.index("Epic: autonomous-orchestrators") < head.index("Phase: 3") \ + < head.index("Blocked-by: PyAutoFit#1") + assert head.index(next(l for l in head if l.startswith("Unattended:"))) \ + < head.index("Lane: local-dev") + # The input's own header block is not echoed into the body. + assert written.count("Epic: autonomous-orchestrators") == 1 + + +# --- (e) census ---------------------------------------------------------------- + +def test_census_lane_histogram_totals_the_prompt_count(tmp_path, capsys): + mind = tmp_path / "mind" + folder = mind / "draft" / "feature" / "x" + folder.mkdir(parents=True) + for name, lane in (("a", "ral"), ("b", "gpu"), ("c", None), ("d", "local-dev"), + ("e", "any | local-dev")): + lane_line = f"Lane: {lane}\n" if lane else "" + (folder / f"{name}.md").write_text( + f"# {name}\n\nType: feature\nTarget: PyAutoBrain\n{lane_line}\nBody.\n") + c = _intake.census(mind) + assert c["by_lane"] == {"any": 2, "gpu": 1, "local-dev": 1, "ral": 1} + assert sum(c["by_lane"].values()) == c["total"] == 5 + assert any("unknown Lane: 'any | local-dev'" in h for h in c["hygiene"]) + _intake.emit_census(c) + assert "By lane: any 2 · gpu 1 · local-dev 1 · ral 1" in capsys.readouterr().out + assert "Lanes: any 2 · gpu 1 · local-dev 1 · ral 1" in _intake.render_dashboard(c) From f3fc8fc792332390b0749f4a4c67b7345f0fddd8 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 3 Nov 2026 14:36:16 +0000 Subject: [PATCH 2/2] fix: keep instance facts out of the new brief module and tests (tenant firewall) Co-Authored-By: Claude Opus 5.5 --- agents/conductors/batch/_brief.py | 2 +- tests/test_orchestrators_lane_brief.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/agents/conductors/batch/_brief.py b/agents/conductors/batch/_brief.py index 38e298df..39c51a01 100644 --- a/agents/conductors/batch/_brief.py +++ b/agents/conductors/batch/_brief.py @@ -26,7 +26,7 @@ _ITEM_RE = re.compile(r"^(\s*)-\s+(.*?)\s*$") _KEY_RE = re.compile(r"^([a-z][a-z0-9-]*):\s*(.*)$") -# A trailing comment needs whitespace before the `#`, so `PyAutoFit#1554` and +# A trailing comment needs whitespace before the `#`, so `Repo#1554` and # `#anchor` paths survive. _COMMENT_RE = re.compile(r"\s+#\s.*$") diff --git a/tests/test_orchestrators_lane_brief.py b/tests/test_orchestrators_lane_brief.py index 39748a08..713d5eb6 100644 --- a/tests/test_orchestrators_lane_brief.py +++ b/tests/test_orchestrators_lane_brief.py @@ -37,7 +37,7 @@ - dispatched: 2026-11-04T08:30Z - review-at: 2026-11-04T11:30Z # stated by the human - shift: morning -- issue: https://github.com/PyAutoLabs/PyAutoMind/issues/1 +- issue: https://github.com/example-owner/mind/issues/1 - packet: yes - heart-ack: - Release YELLOW; monitoring RED @@ -235,21 +235,21 @@ def test_plan_under_a_brief_puts_named_tasks_first_and_never_dips_outside(): # --- (d) intake ---------------------------------------------------------------- def test_intake_proposes_ral_from_the_text(): - d = _intake.analyse("Profile the likelihood on RAL with SLURM for @PyAutoArray.", + d = _intake.analyse("Profile the likelihood on RAL with SLURM for @PyAutoBrain.", "test") assert "\nLane: ral" in d["header"] @pytest.mark.parametrize("text,lane", [ - ("Benchmark the CUDA path on the laptop GPU for @PyAutoArray.", "gpu"), - ("Read the dataset under /mnt/c/Users/x for @PyAutoArray.", "wsl-data"), + ("Benchmark the CUDA path on the laptop GPU for @PyAutoBrain.", "gpu"), + ("Read the dataset under /mnt/c/Users/x for @PyAutoBrain.", "wsl-data"), ]) def test_intake_proposes_gpu_and_wsl_data(text, lane): assert f"\nLane: {lane}" in _intake.analyse(text, "test")["header"] def test_intake_writes_no_lane_for_cloud_runnable_text(): - assert "Lane:" not in _intake.analyse("Tidy a docstring in @PyAutoArray.", + assert "Lane:" not in _intake.analyse("Tidy a docstring in @PyAutoBrain.", "test")["header"] @@ -261,8 +261,8 @@ def test_intake_writes_no_lane_for_cloud_runnable_text(): Epic: autonomous-orchestrators Phase: 3 Filed: 2026-11-03 -Blocked-by: PyAutoFit#1 -Closes-when: PyAutoFit#2 merged +Blocked-by: PyAutoBrain#1 +Closes-when: PyAutoBrain#2 merged Lane: local-dev It submits to RAL via sbatch, but the declared lane wins. @@ -277,13 +277,13 @@ def test_declared_optional_headers_survive_apply_unchanged(tmp_path): written = (mind / rel).read_text() head = written.split("\n\n", 2)[1].splitlines() for line in ("Epic: autonomous-orchestrators", "Phase: 3", "Filed: 2026-11-03", - "Blocked-by: PyAutoFit#1", "Closes-when: PyAutoFit#2 merged", + "Blocked-by: PyAutoBrain#1", "Closes-when: PyAutoBrain#2 merged", "Lane: local-dev"): assert line in head, line # Documented order: pass-through keys after Status:, Lane: after Unattended:. assert head.index("Status: formalised") < head.index("Filed: 2026-11-03") \ < head.index("Epic: autonomous-orchestrators") < head.index("Phase: 3") \ - < head.index("Blocked-by: PyAutoFit#1") + < head.index("Blocked-by: PyAutoBrain#1") assert head.index(next(l for l in head if l.startswith("Unattended:"))) \ < head.index("Lane: local-dev") # The input's own header block is not echoed into the body.