Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion AUTONOMY.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,32 @@ the human did not recall typing that `/prm`.
*Revert condition:* any `reverted` row demotes `glance` back to a human `/prm`
immediately, by dated doctrine edit citing the row — stage 1 or stage 2 alike.

## Stage-2 adversary amendment — 2026-11-04

Amends "The `glance` Shadow-window ruling — 2026-11-04" above, the same day,
by the human, at the phase-4b plan of the autonomous-orchestrators epic. This
section is that dated doctrine edit.

**The adversary leg of an unattended `glance` merge may be the same model as
the author** — Opus 5.5 for both, on cost: the alternatives were a more
expensive model (Fable) or a weaker one (Sonnet). What makes it the leg is
**independence, not the model**: a **fresh close-out session** the watcher
starts after CI lands, which did not write the PR, shares none of the worker's
context, **runs the `Witness:` itself** and reviews the diff adversarially
before it merges or refuses (`agents/conductors/batch/closeout_template.md`).
It applies to the first 10 rows of the stage-2 sub-window exactly as the ruling
says; only "different-model" is replaced.

**Why the ruling asked for a different model, and what is given up.** Two
runs of one model share blind spots, so a subtle error the author made is more
likely to pass a same-model reader. Independence of context still catches what
the leg is mostly for — a skipped or overclaimed witness, an unread label, an
ignored Heart state. The stage-2 window is how this is checked.

*Revert condition:* any stage-2 row `reverted`, or amended to
`merged-after-substantive-change`, restores the different-model requirement for
unattended merges at once, by dated doctrine edit citing the row.

## Shift briefs and unattended `glance` — 2026-11-03

Approved by the human 2026-11-03 as the rulings of the autonomous-orchestrators
Expand Down Expand Up @@ -617,7 +643,9 @@ CI lands**, only when every one of these holds:

Every `/prm` guard applies to that close-out verbatim. These merges count in
their own **stage-2 sub-window**, and each of the first 10 needs the adversary
leg actually run ("The `glance` Shadow-window ruling — 2026-11-04").
leg actually run ("The `glance` Shadow-window ruling — 2026-11-04"). The
leg may be same-model when it is a fresh independent close-out session ("Stage-2
adversary amendment — 2026-11-04").

*Revert condition:* a `reverted` row demotes the tier exactly as the 2026-10-02
section says; one merge past a brief's cap retires this sub-rule.
Expand Down Expand Up @@ -977,6 +1005,8 @@ Codex hook list.
Shadow-window ruling" (2026-11-04): a witness that did not PASS is a human
`/prm`; unattended merges count in a stage-2 sub-window whose first 10 each
need the adversary leg.
- `batch` (the close-out session) — "Stage-2 adversary amendment" (2026-11-04):
the independent close-out session is the adversary leg, same model allowed.
- `batch` (the watcher) — "The watcher's network surface" (2026-11-03): what
the watcher may reach, and that it is opt-in, laptop-only and never writes.

Expand Down
18 changes: 18 additions & 0 deletions agents/conductors/batch/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,24 @@ counters plus slot and mode; `review-minutes-actual` and `overrules` land as
`?` for the human) in the same Mind commit as the digest. A shift whose
`digest.posted` exists writes nothing more — no second append, row or comment.

**Unattended `glance` close-out (phase 4b).** A `glance` worker under a brief
ends at PR-open (`worker_template.md`). Each tick, `_closeouts` first reaps
running close-outs — `<task>.closeout` (`merged` | `refused`, `why`, `gate`,
`model`), or a session gone without one, which is recorded refused — then, for
each PR a worker named, judges the guards it can see with its own reads: the
recorded tier is `glance`, `gh pr checks` is green (pending waits a tick, red is
refused), merges plus in-flight close-outs are under `merge-cap:`, and the
shift is not shadow. Then it starts **one** close-out session
(`closeout_template.md`; `claude --bg --name shift-close:<task> --model opus`).
That session is the independent leg: it runs the `Witness:` itself, reviews the
diff adversarially, checks the `decision-taken` label and Heart, and only then
runs the `/prm` close-out — or refuses. A merge bumps `glance_auto_merges`,
marks the PR merged, and queues a `table-append` op that lands one row in
`autonomy_log.md` "Unattended `glance` sub-window (stage 2)" through the one Mind
writer. Every refusal is final for the shift and named in the digest
(`awaiting /prm — close-out refused: <why>`). Same-model close-out: AUTONOMY
"Stage-2 adversary amendment — 2026-11-04".

**Workers start in a trusted folder.** `claude --bg` refuses an untrusted one
at once ("Workspace not trusted", exit 1) — the spawn fails and leaves no lease.
The watcher launches from the workspace root; task worktrees under the
Expand Down
12 changes: 11 additions & 1 deletion agents/conductors/batch/_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,18 @@ def _line(task: str, counters: dict, tier: str, health) -> str:
if not out["prs"]:
return f"- {task} — finished, no PR or issue named in its .done"
parts = []
closeouts = counters.get("closeouts", {})
for url in out["prs"]:
state = "merged" if url in out["merged"] else health(url)
co = closeouts.get(url, {})
if url in out["merged"]:
state = ("merged unattended (close-out, " + co.get("model", "?") + ")"
if co.get("state") == "merged" else "merged")
elif co.get("state") == "refused":
state = f"awaiting /prm — close-out refused: {co.get('why', '?')}"
elif co.get("state") == "running":
state = "close-out running"
else:
state = health(url)
parts.append(f"{url} — {state}")
return f"- {task} — " + "; ".join(parts) + f" — {tier}"

Expand Down
29 changes: 28 additions & 1 deletion agents/conductors/batch/_mind_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
{"op": "move", "path": "draft/a/b/x.md", "to": "active/x.md"}
{"op": "remove", "path": "active/x.md"}
{"op": "drop-section", "path": "active.md", "section": "task-name"}
{"op": "table-append", "path": "autonomy_log.md", "heading": "## Unattended …", "row": "| … |"}

Every path must be *ledger* by PyAutoMind's own rule (`scripts/ledger_merge.py`
`is_ledger_path`, default deny), so a worker cannot route code through here.
Expand All @@ -31,7 +32,7 @@
import types
from pathlib import Path

OPS = ("append", "write", "move", "remove", "drop-section")
OPS = ("append", "write", "move", "remove", "drop-section", "table-append")
PUSH_TRIES = 3
LEDGER_DIR = "ledger"
COMMITTED_DIR = "committed"
Expand Down Expand Up @@ -80,6 +81,12 @@ def validate_op(op: dict, lm) -> list[str]:
errs.append("drop-section: section is missing")
elif op.get("path") not in lm.ENTRY_MERGED_FILES:
errs.append(f"drop-section: {op.get('path')} is not a `## slug` registry")
if kind == "table-append":
if not str(op.get("heading", "")).startswith("## "):
errs.append("table-append: heading must be a `## ` line")
row = op.get("row")
if not isinstance(row, str) or not row.startswith("|") or "\n" in row:
errs.append("table-append: row must be one `| … |` line")
return errs


Expand Down Expand Up @@ -115,6 +122,26 @@ def apply_op(tree: Path, op: dict, lm) -> None:
if len(kept) == len(entries):
raise MindWriteError(f"drop-section: no `## {op['section']}` in {op['path']}")
target.write_text(lm.join_entries(preamble, kept), encoding="utf-8")
elif kind == "table-append":
target.write_text(table_append(target.read_text(encoding="utf-8")
if target.exists() else "",
op["heading"], op["row"]), encoding="utf-8")


def table_append(text: str, heading: str, row: str) -> str:
"""`row` inserted after the last table line under the `## ` heading that
starts with `heading` — whatever follows that section stays where it is."""
lines = text.splitlines()
start = next((i for i, ln in enumerate(lines) if ln.startswith(heading)), None)
if start is None:
raise MindWriteError(f"table-append: no heading {heading!r}")
end = next((j for j in range(start + 1, len(lines)) if lines[j].startswith("## ")),
len(lines))
rows = [i for i in range(start + 1, end) if lines[i].startswith("|")]
if not rows:
raise MindWriteError(f"table-append: no table under {heading!r}")
lines.insert(rows[-1] + 1, row)
return "\n".join(lines) + "\n"


def _git(cwd: Path, *args, check=True) -> subprocess.CompletedProcess:
Expand Down
Loading
Loading