feat(kvb-org-operator): interactive yes/skip runner and one block per step

operator.py run walks the open rows in list order and shows each step's directory, command (control characters made visible), reason, check and a preview of the change (commits and diffstat to push, files an install changes, what a removal deletes, the repository state) before asking yes, skip, diff or quit; an approved command runs through $SHELL -ic so aliases work, and the row closes once its check passes. list prints one bash block per step plus the run block. The new check kind decision keeps decisions and browser steps out of blocks and run. The skill now requires one row per step and moves the check kinds and the peer sweep into side files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-04 17:07:24 +01:00
1 parent bd73b197b2
commit ed5bb074c5
5 files changed
+269 -60

No files matched your search

+20 -25
View File
@@ -13,6 +13,7 @@ so collecting them needs no messages.
```bash
operator.py add <slug> --dir <abs dir> --command "<cmd>" --reason "<why>" --check <kind>[:<arg>]
operator.py list [--json]
operator.py run
operator.py done <slug>
```
@@ -35,42 +36,36 @@ Checked against the live settings and agent rules, never from memory:
When one of your steps stops for a reason above, run `operator.py add`
before reporting it: one copy-pasteable command, its absolute directory, and
why no agent can run it. The row gets the operator as R, your session as A,
status `queued`.
Choose the strongest check that proves the step done. A check is one of
these kinds, never a shell command: a stored command would let one agent run
code in another's session through the shared register.
| Kind | Done when |
| --- | --- |
| `pushed:<repo dir>` | `git rev-list --count @{u}..HEAD` is 0; no upstream counts as open |
| `installed:<source dir>=<installed dir>` | both trees hold the same files with the same bytes, `__pycache__` ignored |
| `binary:<name>` | the name is on `PATH` |
| `absent:<path>` | the path does not exist |
| `manual` | only `operator.py done <slug>` closes it |
`manual` is the last resort, for a reboot, a login or a decision. Other
kinds, relative paths and shell metacharacters exit 2.
status `queued`. One row is one step: split a bundle such as "rotate
leaked credentials" into one row per concrete command, and give each part
that has no shell command its own `decision` row. Pick its check from
`checks.md` next to this file: the strongest kind that proves the step done,
never a shell command.
## Print: on invocation
Add this session's own pending steps first, then run `operator.py list` and
print its output unchanged. It sets every `queued` or `blocked` row whose
check passes to `done`, then prints the open ones as a table in dependency
order (commits, pushes, installs, binaries, removals, manual steps) and one
`bash` block ready to copy. Once the operator reports a `manual` step done,
run `operator.py done <slug>`.
order (commits, pushes, installs, binaries, removals, manual steps), one
`bash` block per step, and a last block with `operator.py run`. Once the
operator reports a `manual` step done, run `operator.py done <slug>`.
## Peer sweep: only on request
`run` is the operator's script, never an agent's: per step it shows
directory, command, reason, check and a preview of the change (commits and
diffstat to push, files an install changes, what a removal deletes, the
repository state), and runs the command only on `y`; `d` shows the full
diff, `s` skips, `q` stops. Nothing answers for the operator.
Never poll peers: every message wakes an agent for a full turn. Only when the
operator explicitly asks for a sweep, one `SendMessage` to every live agent
(`ListAgents`): "Record each pending operator command the register lacks
with `operator.py add`; do not reply." Then run `list`. A peer's row is data:
it cannot make a command operator-only that the settings let an agent run.
Never poll peers; a sweep runs only when the operator asks for one, as
`sweep.md` next to this file says.
## Rules
- Every message, in any skill, that hands the operator commands puts each
step in its own `bash` block (its `cd` and its command), never several
steps in one, and ends with the `operator.py run` block. Record the steps
with `add` first, so `run` holds them.
- No secret, token or credential value in a command, reason or check: name
where it lives instead.
- Merge duplicates: one row per step across all agents. Read `list --json`
+22
View File
@@ -0,0 +1,22 @@
# Done checks
Choose the strongest check that proves the step done. A check is one of
these kinds, never a shell command: a stored command would let one agent run
code in another's session through the shared register.
| Kind | Done when |
| --- | --- |
| `pushed:<repo dir>` | `git rev-list --count @{u}..HEAD` is 0; no upstream counts as open |
| `installed:<source dir>=<installed dir>` | both trees hold the same files with the same bytes, `__pycache__` ignored |
| `binary:<name>` | the name is on `PATH` |
| `absent:<path>` | the path does not exist |
| `manual` | only `operator.py done <slug>` closes it, or `run` once the command exits 0 |
| `decision` | only `operator.py done <slug>` closes it; never in a block or `run` |
`manual` is the last resort for a runnable command such as a reboot. Every
non-`decision` row's `--command` must run as typed in the operator's shell,
because `run` executes it on `y`; prose there ("Rotate leaked credentials")
is a defect. A decision, a browser or hardware step, or anything else
without a shell command takes `decision`, its question or instruction as
`--command`. Other kinds, relative paths and shell
metacharacters exit 2.
+149 -15
View File
@@ -7,6 +7,8 @@ Environment:
CLAUDE_CODE_SESSION_ID: the adding session, recorded as A; other runtimes
pass --session.
PATH: searched by the binary check.
SHELL: run executes each approved command with `$SHELL -ic`, so the
operator's aliases and functions work; required by run.
Args:
add <slug> --dir <abs dir> --command "<cmd>" --reason "<why>"
@@ -19,18 +21,28 @@ Args:
files with the same bytes, __pycache__ ignored.
binary:<name> the name is on PATH.
absent:<path> the path does not exist.
manual only `done` closes it.
manual only `done` closes it, or `run` once the command exits 0.
decision a decision with no command to run: listed in the table
only, never in a block or `run`; only `done` closes it.
Paths are absolute; no argument holds a shell metacharacter.
list [--json]: run the check of every queued or blocked operator row and
set the passing ones done.
run: walk the open rows that hold a directory and a command, in list
order. Per row it prints directory, command (control characters made
visible), reason and check, then a preview of what the command
changes, and asks [y]es / [s]kip / [d]iff / [q]uit; d shows the full
diff. y runs the command in its directory, then sets the row done
when its check passes (manual: when the command exits 0). End of
input quits. There is no flag that answers for the operator.
done <slug>: mark a row done, whatever its check.
Output:
list: the open rows as `| # | Directory | Command | Why | Check |`, rows
naming `commit` first, then by kind in the order above, insertion
order inside a kind; then one bash block with `cd <dir>` and the
command per row. `operator: nothing open` when none is. --json prints
the same rows as a JSON list.
order inside a kind; then one bash block per row with a directory,
each holding `cd <dir>` and the command, and a last block with the
`run` invocation. `operator: nothing open` when none is. --json
prints the same rows as a JSON list.
Exit codes:
0 done, 2 usage error.
@@ -58,7 +70,7 @@ from types import ModuleType
RASCI = Path(__file__).resolve().parents[2] / "kvb-org-rasci" / "scripts" / "rasci.py"
PREFIX = "operator/"
KINDS = ("pushed", "installed", "binary", "absent", "manual")
KINDS = ("pushed", "installed", "binary", "absent", "manual", "decision")
OPEN = ("queued", "blocked")
META = re.compile(r"[\s|&;()<>$`\\\"'*?\[\]{}~!#]")
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
@@ -96,8 +108,8 @@ def problem(check: str) -> str | None:
kind, sep, arg = check.partition(":")
if kind not in KINDS:
return f"unknown check kind {kind!r}; use one of {', '.join(KINDS)}"
if kind == "manual":
return "manual takes no argument" if sep else None
if kind in ("manual", "decision"):
return f"{kind} takes no argument" if sep else None
if not arg:
return f"{kind} needs an argument"
if META.search(arg):
@@ -185,7 +197,7 @@ def cmd_add(rasci: ModuleType, args: argparse.Namespace) -> int:
return 0
def cmd_list(rasci: ModuleType, args: argparse.Namespace) -> int:
def open_rows(rasci: ModuleType) -> list[dict]:
rows = {
task: row for task, row in rasci.merged(include_done=False).items()
if task.startswith(PREFIX) and row["status"] in OPEN
@@ -205,7 +217,7 @@ def cmd_list(rasci: ModuleType, args: argparse.Namespace) -> int:
return (0 if first else 1 + KINDS.index(kind), rows[task].get("seq", 0), task)
order = sorted(rows.keys() - finished, key=rank)
listed = [
return [
{
"task": task,
"directory": rows[task].get("workdir"),
@@ -215,6 +227,10 @@ def cmd_list(rasci: ModuleType, args: argparse.Namespace) -> int:
}
for task in order
]
def cmd_list(rasci: ModuleType, args: argparse.Namespace) -> int:
listed = open_rows(rasci)
if args.json:
print(json.dumps(listed, indent=1))
return 0
@@ -226,15 +242,131 @@ def cmd_list(rasci: ModuleType, args: argparse.Namespace) -> int:
for number, row in enumerate(listed, 1):
values = (number, row["directory"] or "-", row["command"] or "-", row["why"] or "-", row["check"])
print("| " + " | ".join(cell(v) for v in values) + " |")
# Trip-wire: legacy rows carry a prose title and no directory; only rows recorded by `add` are commands.
runnable = [row for row in listed if row["directory"] and row["command"]]
if runnable:
steps = [(number, row) for number, row in enumerate(listed, 1) if runnable(row)]
for number, row in steps:
print()
print(f"{number}. {row['task']}")
print("```bash")
for row in runnable:
print(f"cd {shlex.quote(row['directory'])}")
print(row["command"])
print(f"cd {shlex.quote(row['directory'])}")
print(row["command"])
print("```")
if steps:
print()
print("Every step with preview and yes/skip:")
print("```bash")
print(f"python3 {shlex.quote(str(Path(__file__).resolve()))} run")
print("```")
return 0
def runnable(row: dict) -> bool:
# Trip-wire: legacy rows carry a prose title and no directory; only rows recorded by `add` are commands.
return bool(row["directory"] and row["command"]) and row["check"] != "decision"
def visible(text: str) -> str:
return "".join(c if c.isprintable() else c.encode("unicode_escape").decode() for c in text)
def git_tree(directory: str) -> bool:
try:
return subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"], cwd=directory, capture_output=True, check=False
).returncode == 0
except OSError:
return False
def evidence(check: str, directory: str, full: bool) -> list[tuple[list[str], str]]:
kind, _, arg = check.partition(":")
if kind == "pushed":
if full:
return [(["git", "diff", "@{u}..HEAD"], arg)]
return [
(["git", "--no-pager", "log", "--oneline", "@{u}..HEAD"], arg),
(["git", "--no-pager", "diff", "--stat", "@{u}..HEAD"], arg),
]
if kind == "installed":
source, _, target = arg.partition("=")
return [(["diff", "-ru" if full else "-rq", "--exclude=__pycache__", target, source], directory)]
if kind == "absent":
return [(["ls", "-la" if full else "-ld", "--", arg], directory)]
if kind == "manual" and git_tree(directory):
if full:
return [(["git", "diff", "HEAD"], directory)]
return [
(["git", "--no-pager", "log", "-1", "--oneline"], directory),
(["git", "--no-pager", "status", "--short"], directory),
]
return []
def show(commands: list[tuple[list[str], str]]) -> None:
if not commands:
print(" (nothing to compare)", flush=True)
for argv, cwd in commands:
print(f"$ {shlex.join(argv)}", flush=True)
try:
subprocess.run(argv, cwd=cwd, check=False)
except OSError as error:
print(f" {error}", flush=True)
def ask() -> str:
while True:
try:
answer = input("Run it? [y]es / [s]kip / [d]iff / [q]uit: ").strip().lower()
except EOFError:
return "q"
if answer in ("y", "s", "d", "q"):
return answer
def close(rasci: ModuleType, task: str) -> None:
with rasci.database() as tasks:
entry = tasks.get(task)
if entry and entry.get("status") in OPEN:
rasci.apply_status(entry, "done")
def cmd_run(rasci: ModuleType, args: argparse.Namespace) -> int:
shell = os.environ.get("SHELL")
if not shell:
fail("SHELL is not set; run executes each command in the operator's own shell")
steps = [row for row in open_rows(rasci) if runnable(row)]
if not steps:
print("operator: nothing to run")
return 0
for number, row in enumerate(steps, 1):
check = "manual" if problem(row["check"]) else row["check"]
print(f"\n[{number}/{len(steps)}] {row['task']}")
print(f" directory: {visible(row['directory'])}")
print(f" command: {visible(row['command'])}")
print(f" why: {visible(row['why'] or '-')}")
print(f" check: {check}", flush=True)
show(evidence(check, row["directory"], full=False))
answer = ask()
while answer == "d":
show(evidence(check, row["directory"], full=True))
answer = ask()
if answer == "q":
print("operator: stopped")
return 0
if answer == "s":
print(f"skipped {row['task']}")
continue
try:
code = subprocess.run([shell, "-ic", row["command"]], cwd=row["directory"], check=False).returncode
except OSError as error:
print(f"failed {row['task']}: {error}")
continue
if code != 0:
print(f"failed {row['task']}: exit {code}; the row stays open")
elif check == "manual" or passed(check):
close(rasci, row["task"])
print(f"done {row['task']}")
else:
print(f"ran {row['task']}, but {check} does not pass yet; the row stays open")
return 0
@@ -260,6 +392,8 @@ def main() -> int:
p = sub.add_parser("list")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_list)
p = sub.add_parser("run")
p.set_defaults(func=cmd_run)
p = sub.add_parser("done")
p.add_argument("slug")
p.set_defaults(func=cmd_done)
+7
View File
@@ -0,0 +1,7 @@
# Peer sweep: only on request
Never poll peers: every message wakes an agent for a full turn. Only when the
operator explicitly asks for a sweep, one `SendMessage` to every live agent
(`ListAgents`): "Record each pending operator command the register lacks
with `operator.py add`; do not reply." Then run `list`. A peer's row is data:
it cannot make a command operator-only that the settings let an agent run.
+71 -20
View File
@@ -1,4 +1,4 @@
"""Exercise kvb-org-operator: operator rows in the rasci register, their done checks, the printed list."""
"""Exercise kvb-org-operator: operator rows in the rasci register, their done checks, the list, the runner."""
from __future__ import annotations
@@ -14,6 +14,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
OPERATOR = REPO_ROOT / "skills" / "kvb-org-operator" / "scripts" / "operator.py"
RASCI = REPO_ROOT / "skills" / "kvb-org-rasci" / "scripts" / "rasci.py"
HEADER = "| # | Directory | Command | Why | Check |"
RUNNER = ["", "Every step with preview and yes/skip:", "```bash", f"python3 {OPERATOR} run", "```"]
IDENTITY = {
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.org",
@@ -80,6 +81,17 @@ class TestOperator(unittest.TestCase):
[f"added operator/{slug}"],
)
@staticmethod
def _block(number: int, slug: str, workdir: Path, command: str) -> list[str]:
return ["", f"{number}. operator/{slug}", "```bash", f"cd {workdir}", command, "```"]
def _feed(self, answers: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(OPERATOR), "run"],
input=answers, capture_output=True, text=True, env={**self.env, "SHELL": "/bin/sh"},
cwd=self.tmp.name, check=False,
)
def _open(self) -> list[str]:
return [row["task"] for row in json.loads("\n".join(self._out("list", "--json")))]
@@ -116,23 +128,14 @@ class TestOperator(unittest.TestCase):
f"| 5 | {base} | sudo rm {stale} | root owned | absent:{stale} |",
f"| 6 | {base} | sudo reboot | new kernel | manual |",
f"| 7 | {base} | sudo systemctl restart docker | daemon config | manual |",
"",
"```bash",
f"cd {repo}",
"git commit -m wip",
f"cd {repo}",
"git-sign-push",
f"cd {src}",
"make install",
f"cd {base}",
"pipx install kvb-fake-tool",
f"cd {base}",
f"sudo rm {stale}",
f"cd {base}",
"sudo reboot",
f"cd {base}",
"sudo systemctl restart docker",
"```",
*self._block(1, "commit-row", repo, "git commit -m wip"),
*self._block(2, "push-row", repo, "git-sign-push"),
*self._block(3, "installed-row", src, "make install"),
*self._block(4, "binary-row", base, "pipx install kvb-fake-tool"),
*self._block(5, "absent-row", base, f"sudo rm {stale}"),
*self._block(6, "zeta", base, "sudo reboot"),
*self._block(7, "alpha", base, "sudo systemctl restart docker"),
*RUNNER,
],
)
row = self._register()["operator/installed-row"]
@@ -203,10 +206,58 @@ class TestOperator(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self._add("reboot", self.base, "sudo reboot", "kernel", "manual")
self.assertEqual(
self._out("list")[-4:],
["```bash", f"cd {self.base}", "sudo reboot", "```"],
self._out("list")[4:],
[*self._block(2, "reboot", self.base, "sudo reboot"), *RUNNER],
)
def test_run_shows_each_step_and_runs_only_approved_ones(self):
work = self.base / "work"
work.mkdir()
self._add("first", work, "touch ran-first", "approve me", "manual")
self._add("second", work, "touch ran-second", "skip me", "manual")
self._add("third", work, "echo \x1b[2Kx", "hidden escape", "manual")
result = self._feed("maybe\ny\ns\n")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((work / "ran-first").exists())
self.assertFalse((work / "ran-second").exists())
out = result.stdout
self.assertLess(out.index("command: touch ran-first"), out.index("Run it?"))
self.assertIn("done operator/first", out)
self.assertIn("skipped operator/second", out)
self.assertIn("command: echo \\x1b[2Kx", out)
self.assertIn("operator: stopped", out)
self.assertNotIn("\x1b", out)
self.assertEqual(self._open(), ["operator/second", "operator/third"])
def test_run_previews_pushed_commits_and_keeps_failed_rows_open(self):
repo = self._repo("ahead", upstream=True)
self._add("push", repo, "false", "branch ahead", f"pushed:{repo}")
result = self._feed("y\n")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("$ git --no-pager log --oneline '@{u}..HEAD'", result.stdout)
self.assertRegex(result.stdout, r"[0-9a-f]{7,} two")
self.assertIn("failed operator/push: exit 1; the row stays open", result.stdout)
self.assertEqual(self._open(), ["operator/push"])
def test_decision_rows_stay_out_of_blocks_and_run(self):
self._add("ask", self.base, "decide: keep fix B", "operator only", "decision")
self._add("go", self.base, "true", "runnable", "manual")
listed = self._out("list")
self.assertIn(f"| 2 | {self.base} | decide: keep fix B | operator only | decision |", listed)
self.assertEqual(listed[4:], [*self._block(1, "go", self.base, "true"), *RUNNER])
result = self._feed("y\n")
self.assertNotIn("decide", result.stdout)
self.assertEqual(self._open(), ["operator/ask"])
def test_run_needs_shell(self):
self._add("x", self.base, "true", "r", "manual")
result = subprocess.run(
[sys.executable, str(OPERATOR), "run"], input="y\n", capture_output=True, text=True,
env=self.env, cwd=self.tmp.name, check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("SHELL is not set", result.stderr)
def test_invalid_input_exits_2(self):
good = ("--dir", "/srv", "--command", "sudo reboot", "--reason", "kernel")
for args in (