diff --git a/skills/kvb-org-operator/SKILL.md b/skills/kvb-org-operator/SKILL.md index e890824..71ea67f 100644 --- a/skills/kvb-org-operator/SKILL.md +++ b/skills/kvb-org-operator/SKILL.md @@ -13,6 +13,7 @@ so collecting them needs no messages. ```bash operator.py add --dir --command "" --reason "" --check [:] operator.py list [--json] +operator.py run operator.py done ``` @@ -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:` | `git rev-list --count @{u}..HEAD` is 0; no upstream counts as open | -| `installed:=` | both trees hold the same files with the same bytes, `__pycache__` ignored | -| `binary:` | the name is on `PATH` | -| `absent:` | the path does not exist | -| `manual` | only `operator.py done ` 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 `. +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 `. -## 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` diff --git a/skills/kvb-org-operator/checks.md b/skills/kvb-org-operator/checks.md new file mode 100644 index 0000000..1b0413c --- /dev/null +++ b/skills/kvb-org-operator/checks.md @@ -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:` | `git rev-list --count @{u}..HEAD` is 0; no upstream counts as open | +| `installed:=` | both trees hold the same files with the same bytes, `__pycache__` ignored | +| `binary:` | the name is on `PATH` | +| `absent:` | the path does not exist | +| `manual` | only `operator.py done ` closes it, or `run` once the command exits 0 | +| `decision` | only `operator.py done ` 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. diff --git a/skills/kvb-org-operator/scripts/operator.py b/skills/kvb-org-operator/scripts/operator.py index 1cb389a..d1d6ddc 100755 --- a/skills/kvb-org-operator/scripts/operator.py +++ b/skills/kvb-org-operator/scripts/operator.py @@ -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 --dir --command "" --reason "" @@ -19,18 +21,28 @@ Args: files with the same bytes, __pycache__ ignored. binary: the name is on PATH. absent: 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 : 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 ` 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 ` 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) diff --git a/skills/kvb-org-operator/sweep.md b/skills/kvb-org-operator/sweep.md new file mode 100644 index 0000000..b9601db --- /dev/null +++ b/skills/kvb-org-operator/sweep.md @@ -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. diff --git a/tests/test_operator.py b/tests/test_operator.py index a4de7cd..d775fb8 100644 --- a/tests/test_operator.py +++ b/tests/test_operator.py @@ -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 (