graphban-cli 0.8.6__tar.gz → 0.8.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/.gitignore +1 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/PKG-INFO +1 -1
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/pyproject.toml +1 -1
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/cli.py +39 -2
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/doctor.py +16 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/gitignore.py +1 -0
- graphban_cli-0.8.8/src/gban/playbooks.py +217 -0
- graphban_cli-0.8.8/src/gban/skills/graphban-prove/SKILL.md +20 -0
- graphban_cli-0.8.8/src/gban/skills/graphban-ship/SKILL.md +36 -0
- graphban_cli-0.8.8/src/gban/skills/graphban-unstick/SKILL.md +30 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-wave/SKILL.md +4 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/watch-wave/SKILL.md +2 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_acts.py +1 -1
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_machine_report.py +2 -0
- graphban_cli-0.8.8/tests/test_playbooks.py +325 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_setup.py +58 -4
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/uv.lock +1 -1
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/LICENSE +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/README.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/__init__.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/client.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/config.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/machine.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/setup.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-answer/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-grill/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-orient/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-prd/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-review/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-scan/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/skills/graphban-work/SKILL.md +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/stackmap.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/src/gban/swamp.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_doctor.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_login_pair.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_packaging.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_session.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_stackmap.py +0 -0
- {graphban_cli-0.8.6 → graphban_cli-0.8.8}/tests/test_swamp.py +0 -0
|
@@ -16,7 +16,7 @@ import sys
|
|
|
16
16
|
import time
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
-
from gban import config, doctor as doctor_mod, machine as machine_mod, setup as setup_mod, stackmap, swamp as swamp_mod
|
|
19
|
+
from gban import config, doctor as doctor_mod, machine as machine_mod, playbooks, setup as setup_mod, stackmap, swamp as swamp_mod
|
|
20
20
|
from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
|
|
21
21
|
Client, NoSession,
|
|
22
22
|
Refused, Unreachable, authenticated, login, pair_poll,
|
|
@@ -51,6 +51,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
51
51
|
help=("sign in without typing a password here: prints a short code that a signed-in "
|
|
52
52
|
"person approves in the web app's quick-start dialog"))
|
|
53
53
|
|
|
54
|
+
sub.add_parser("update", help="poll the deployment now and refresh the agent files")
|
|
54
55
|
sub.add_parser("logout", help="end the session, here and on the server")
|
|
55
56
|
doctor_cmd = sub.add_parser(
|
|
56
57
|
"doctor", help="check both halves: the ledger, and the local fleet",
|
|
@@ -370,6 +371,9 @@ def cmd_doctor(args) -> int:
|
|
|
370
371
|
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
371
372
|
api_key = doctor_mod.resolve_supervisor_key(project, url=url)
|
|
372
373
|
lines, code = doctor_mod.run(url, project, api_key)
|
|
374
|
+
poll_line = doctor_mod.playbooks_line(getattr(args, "poll_result", None))
|
|
375
|
+
if poll_line:
|
|
376
|
+
lines.append(poll_line)
|
|
373
377
|
if not getattr(args, "no_report", False):
|
|
374
378
|
lines += machine_mod.send(url, project, lines) # UNKNOWN when undelivered: exit unchanged
|
|
375
379
|
_out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
|
|
@@ -704,13 +708,46 @@ def cmd_keys(args) -> int:
|
|
|
704
708
|
return 0
|
|
705
709
|
|
|
706
710
|
|
|
707
|
-
|
|
711
|
+
def cmd_update(args) -> int:
|
|
712
|
+
return 0 # the poll itself runs in main(), forced for this verb
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
COMMANDS = {"update": cmd_update, "login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
|
|
708
716
|
"doctor": cmd_doctor, "setup": cmd_setup, "fleet": cmd_fleet, "seats": cmd_seats,
|
|
709
717
|
"agents": cmd_agents, "keys": cmd_keys, "swamp": cmd_swamp, "stack": cmd_stack}
|
|
710
718
|
|
|
711
719
|
|
|
720
|
+
def _poll(args) -> None:
|
|
721
|
+
"""The hourly poll every command rides. Never fails the command itself."""
|
|
722
|
+
if args.command == "logout":
|
|
723
|
+
return
|
|
724
|
+
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
725
|
+
if not url:
|
|
726
|
+
return
|
|
727
|
+
try:
|
|
728
|
+
# The checkout root, not the cwd: from `cli/` the receipt and the pack still land at
|
|
729
|
+
# the top of the repository.
|
|
730
|
+
repo = playbooks.checkout(Path.cwd())
|
|
731
|
+
if repo is None: # not in a checkout: there is nowhere the agent files belong
|
|
732
|
+
result = {"note": "not inside a git checkout; nothing to refresh"}
|
|
733
|
+
else:
|
|
734
|
+
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
735
|
+
result = playbooks.poll(url, repo, force=args.command == "update", project=project)
|
|
736
|
+
except Exception as exc: # a poll must not break the verb the person typed
|
|
737
|
+
result = {"note": f"poll skipped: {exc}"}
|
|
738
|
+
args.poll_result = result
|
|
739
|
+
if args.command == "doctor":
|
|
740
|
+
return # doctor reports it as its own line, saying whether it looked just now
|
|
741
|
+
if "polled" not in result and args.command != "update":
|
|
742
|
+
return # nothing was checked; a non-update verb stays quiet about it
|
|
743
|
+
if args.command == "update" or not args.as_json:
|
|
744
|
+
print(f"{PROG}: {result['note']}",
|
|
745
|
+
file=sys.stdout if args.command == "update" else sys.stderr)
|
|
746
|
+
|
|
747
|
+
|
|
712
748
|
def main(argv: list[str] | None = None) -> int:
|
|
713
749
|
args = _parser().parse_args(argv)
|
|
750
|
+
_poll(args)
|
|
714
751
|
try:
|
|
715
752
|
return COMMANDS[args.command](args)
|
|
716
753
|
except NoSession as exc:
|
|
@@ -392,6 +392,22 @@ def child_environment(api_key: str) -> dict:
|
|
|
392
392
|
return env
|
|
393
393
|
|
|
394
394
|
|
|
395
|
+
def playbooks_line(result: dict | None) -> dict | None:
|
|
396
|
+
"""The hourly poll as one finding (GRPH-P65).
|
|
397
|
+
|
|
398
|
+
A fresh stamp means nothing was asked of the server: the line gives the stored poll time
|
|
399
|
+
and sha and is UNKNOWN, never a PASS — a skipped poll reported as a clean check is the bug.
|
|
400
|
+
"""
|
|
401
|
+
if not result:
|
|
402
|
+
return None
|
|
403
|
+
note = result.get("note", "")
|
|
404
|
+
if result.get("skipped"):
|
|
405
|
+
return _line("local", UNKNOWN, "playbooks", f"not checked now — {note}")
|
|
406
|
+
if result.get("failed") or "polled" not in result:
|
|
407
|
+
return _line("local", UNKNOWN, "playbooks", note)
|
|
408
|
+
return _line("local", PASS, "playbooks", f"polled just now — {note}")
|
|
409
|
+
|
|
410
|
+
|
|
395
411
|
def run(url: str, project: str, api_key: str = "",
|
|
396
412
|
repo: Path | None = None) -> tuple[list[dict], int]:
|
|
397
413
|
lines = (ledger(url, project) + local(url, project, api_key)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The hourly poll that refreshes the agent files from the deployment (GRPH-P65).
|
|
2
|
+
|
|
3
|
+
Every `gban` command may poll `/health`; `gban update` always does. The receipt
|
|
4
|
+
`.graphban/playbooks.json` (gitignored) holds `polled_at` (Unix seconds), the server URL, the
|
|
5
|
+
`git_sha` last applied, and the sha256 of every file this tool wrote.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess # nosec B404
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from gban import config
|
|
18
|
+
from gban.client import Client, NoSession, Refused, Unreachable, authenticated
|
|
19
|
+
|
|
20
|
+
RECEIPT = Path(".graphban") / "playbooks.json"
|
|
21
|
+
INTERVAL = 3600
|
|
22
|
+
#: A `skill` entry is published once, under `.claude/skills/<slug>/SKILL.md`; every harness
|
|
23
|
+
#: reads its own directory (`gbagent` reads all three), so the client writes it to each.
|
|
24
|
+
SKILL_DIRS = (".claude/skills", ".grok/skills", ".agents/skills")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def targets(entry: dict) -> list[str]:
|
|
28
|
+
"""Every checkout path one pack entry is written to."""
|
|
29
|
+
rel = entry.get("path")
|
|
30
|
+
if entry.get("kind") == "skill" and isinstance(rel, str):
|
|
31
|
+
for base in SKILL_DIRS:
|
|
32
|
+
if rel.startswith(base + "/"):
|
|
33
|
+
tail = rel[len(base) + 1:]
|
|
34
|
+
return [f"{b}/{tail}" for b in SKILL_DIRS]
|
|
35
|
+
return [rel]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write(dest: Path, data: bytes) -> None:
|
|
39
|
+
"""Whole file or nothing: a `gbagent` child spawned mid-apply never reads half a body."""
|
|
40
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp = dest.with_name(dest.name + ".gban-tmp")
|
|
42
|
+
tmp.write_bytes(data)
|
|
43
|
+
os.replace(tmp, dest)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read(repo: Path) -> dict:
|
|
47
|
+
try:
|
|
48
|
+
loaded = json.loads((repo / RECEIPT).read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
return {}
|
|
51
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _save(repo: Path, receipt: dict) -> None:
|
|
55
|
+
path = repo / RECEIPT
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(json.dumps(receipt, indent=1, sort_keys=True) + "\n", encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sha(data: bytes) -> str:
|
|
61
|
+
return hashlib.sha256(data).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def when(ts: float) -> str:
|
|
65
|
+
return time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime(ts))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def checkout(start: Path) -> Path | None:
|
|
69
|
+
"""The git toplevel containing `start`, or None outside a checkout.
|
|
70
|
+
|
|
71
|
+
The receipt and every pack path are relative to the checkout root, so running `gban`
|
|
72
|
+
from `cli/` must not plant `.graphban/` and the skills under `cli/`.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
proc = subprocess.run( # nosec B603 B607
|
|
76
|
+
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
|
|
77
|
+
capture_output=True, text=True, timeout=30, check=False)
|
|
78
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
79
|
+
return None
|
|
80
|
+
top = (proc.stdout or "").strip()
|
|
81
|
+
return Path(top) if proc.returncode == 0 and top else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def credentialed(url: str, project: str = "") -> Client:
|
|
85
|
+
"""A client that may read `/api/playbooks`: the `gban login` session, else the API key
|
|
86
|
+
(`$GRAPHBAN_API_KEY`, then the key `gban setup` stored for this project).
|
|
87
|
+
|
|
88
|
+
Raises `NoSession` when there is none. `/health` stays unauthenticated; only the pack,
|
|
89
|
+
which carries the bodies, needs this.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
return authenticated(url, act="update")
|
|
93
|
+
except NoSession:
|
|
94
|
+
key = (os.environ.get(config.API_KEY_ENV) or "").strip() \
|
|
95
|
+
or config.stored_supervisor_key(project)
|
|
96
|
+
if not key:
|
|
97
|
+
raise
|
|
98
|
+
return Client(url, api_key=key)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def poll(url: str, repo: Path, *, force: bool = False, client: Client | None = None,
|
|
102
|
+
auth: Callable[[], Client] | None = None, project: str = "",
|
|
103
|
+
now: float | None = None) -> dict:
|
|
104
|
+
"""One poll decision. Returns `{"polled": bool, "note": str, ...}`; never raises for an
|
|
105
|
+
unreachable or unreadable `/health` (that leaves `polled_at` alone).
|
|
106
|
+
|
|
107
|
+
`client` reads `/health`, which needs no credential. `auth` builds the client for the
|
|
108
|
+
pack, which does: by default the `gban login` session or the stored API key.
|
|
109
|
+
"""
|
|
110
|
+
now = time.time() if now is None else now
|
|
111
|
+
url = url.rstrip("/")
|
|
112
|
+
receipt = _read(repo)
|
|
113
|
+
polled_at = receipt.get("polled_at")
|
|
114
|
+
if (not force and isinstance(polled_at, (int, float)) and receipt.get("server") == url
|
|
115
|
+
and 0 <= now - polled_at < INTERVAL):
|
|
116
|
+
stored = receipt.get("git_sha") or ""
|
|
117
|
+
at = f" at {stored[:12]}" if stored else (" (no pack applied)")
|
|
118
|
+
return {"polled": False, "skipped": True, "polled_at": polled_at, "git_sha": stored,
|
|
119
|
+
"note": f"last poll {when(polled_at)}{at}; not checked against the server "
|
|
120
|
+
"again within the hour"}
|
|
121
|
+
if auth is None:
|
|
122
|
+
auth = lambda: credentialed(url, project) # noqa: E731
|
|
123
|
+
client = client or Client(url)
|
|
124
|
+
try:
|
|
125
|
+
health = client.call("GET", "/health")
|
|
126
|
+
except (Unreachable, Refused) as exc:
|
|
127
|
+
return {"polled": False, "failed": True, "note": f"could not poll /health: {exc}"}
|
|
128
|
+
if not isinstance(health, dict):
|
|
129
|
+
return {"polled": False, "failed": True, "note": "unreadable /health"}
|
|
130
|
+
sha = str(health.get("git_sha") or "").strip()
|
|
131
|
+
# A different server never inherits the old server's applied sha or file hashes.
|
|
132
|
+
base = receipt if receipt.get("server") == url else {}
|
|
133
|
+
new = {"server": url, "polled_at": now, "git_sha": base.get("git_sha", ""),
|
|
134
|
+
"files": base.get("files", {})}
|
|
135
|
+
if not sha:
|
|
136
|
+
_save(repo, new)
|
|
137
|
+
return {"polled": True, "polled_at": now, "note": "deployment is unstamped (empty git_sha)"}
|
|
138
|
+
if sha == base.get("git_sha"):
|
|
139
|
+
_save(repo, new)
|
|
140
|
+
return {"polled": True, "polled_at": now, "git_sha": sha, "note": f"current at {sha[:12]}"}
|
|
141
|
+
try:
|
|
142
|
+
pack = auth().call("GET", "/api/playbooks")
|
|
143
|
+
except NoSession:
|
|
144
|
+
return {"polled": False, "failed": True,
|
|
145
|
+
"note": "no credential to fetch the pack; run `gban login` or `gban setup`"}
|
|
146
|
+
except Refused as exc:
|
|
147
|
+
if exc.status == 404:
|
|
148
|
+
new["no_pack"] = True
|
|
149
|
+
_save(repo, new)
|
|
150
|
+
return {"polled": True, "polled_at": now,
|
|
151
|
+
"note": "this server publishes no pack; setup keeps the wheel copy"}
|
|
152
|
+
return {"polled": False, "failed": True, "note": f"could not fetch pack: {exc}"}
|
|
153
|
+
except Unreachable as exc:
|
|
154
|
+
return {"polled": False, "failed": True, "note": f"could not fetch pack: {exc}"}
|
|
155
|
+
files = pack.get("files") if isinstance(pack, dict) else None
|
|
156
|
+
if not isinstance(files, list) or pack.get("git_sha") != sha:
|
|
157
|
+
return {"polled": False, "failed": True,
|
|
158
|
+
"note": "deployment identity is split or the pack is unreadable; wrote nothing"}
|
|
159
|
+
# Validate the whole pack before touching a file: a partial or unreadable payload
|
|
160
|
+
# writes nothing.
|
|
161
|
+
plan: dict[str, bytes] = {}
|
|
162
|
+
for entry in files:
|
|
163
|
+
if not isinstance(entry, dict):
|
|
164
|
+
return {"polled": False, "failed": True, "note": "unreadable pack; wrote nothing"}
|
|
165
|
+
body = entry.get("body")
|
|
166
|
+
if not isinstance(body, str):
|
|
167
|
+
return {"polled": False, "failed": True, "note": "unreadable pack; wrote nothing"}
|
|
168
|
+
for rel in targets(entry):
|
|
169
|
+
if not isinstance(rel, str) or not rel or rel.startswith(("/", "\\")) \
|
|
170
|
+
or ".." in Path(rel).parts or Path(rel).is_absolute():
|
|
171
|
+
return {"polled": False, "failed": True,
|
|
172
|
+
"note": "unreadable pack; wrote nothing"}
|
|
173
|
+
plan[rel] = body.encode("utf-8")
|
|
174
|
+
applied = apply(repo, plan, dict(base.get("files", {})))
|
|
175
|
+
owned, written, kept = applied["files"], applied["written"], applied["kept"]
|
|
176
|
+
gone = applied["gone"]
|
|
177
|
+
new.update(git_sha=sha, files=owned)
|
|
178
|
+
_save(repo, new)
|
|
179
|
+
note = f"applied {sha[:12]}: {len(written)} file(s) written"
|
|
180
|
+
if kept:
|
|
181
|
+
note += "; local edits left in place: " + ", ".join(kept)
|
|
182
|
+
if gone:
|
|
183
|
+
note += "; no longer in the pack: " + ", ".join(gone)
|
|
184
|
+
return {"polled": True, "polled_at": now, "git_sha": sha, "note": note,
|
|
185
|
+
"written": written, "kept": kept, "gone": gone}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def apply(repo: Path, plan: dict[str, bytes], owned: dict[str, str]) -> dict:
|
|
189
|
+
"""Write `plan` (path -> bytes) into `repo` by the ownership rules of GRPH-P65.
|
|
190
|
+
|
|
191
|
+
- receipt says we wrote it and the new body differs: replace it;
|
|
192
|
+
- bytes already match: leave the file, record the hash (this adopts a committed copy);
|
|
193
|
+
- missing: write it, record the hash;
|
|
194
|
+
- matches neither the receipt nor the pack: a local edit, left and named;
|
|
195
|
+
- in the receipt but no longer in the pack: left on disk and named.
|
|
196
|
+
"""
|
|
197
|
+
owned = dict(owned)
|
|
198
|
+
written, kept = [], []
|
|
199
|
+
for rel, data in plan.items():
|
|
200
|
+
dest, digest = repo / rel, _sha(data)
|
|
201
|
+
if dest.is_file():
|
|
202
|
+
have = _sha(dest.read_bytes())
|
|
203
|
+
if have != digest:
|
|
204
|
+
if owned.get(rel) != have:
|
|
205
|
+
kept.append(rel)
|
|
206
|
+
continue
|
|
207
|
+
_write(dest, data)
|
|
208
|
+
written.append(rel)
|
|
209
|
+
elif dest.exists(): # a directory where a file belongs: not ours to remove
|
|
210
|
+
kept.append(rel)
|
|
211
|
+
continue
|
|
212
|
+
else:
|
|
213
|
+
_write(dest, data)
|
|
214
|
+
written.append(rel)
|
|
215
|
+
owned[rel] = digest
|
|
216
|
+
gone = sorted(r for r in owned if r not in plan)
|
|
217
|
+
return {"files": owned, "written": written, "kept": kept, "gone": gone}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphban-prove
|
|
3
|
+
description: >
|
|
4
|
+
The finish bar before a builder moves an item to review: both database
|
|
5
|
+
engines, sabotage of the caller, one read of the live surface. Use before
|
|
6
|
+
update_item sets status=review, or when the user runs /graphban-prove.
|
|
7
|
+
user-invocable: true
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Prove it before review
|
|
11
|
+
|
|
12
|
+
A green unit suite is not the bar. Run this before `update_item` moves the item to `review`. `graphban-work` says evidence appends; this is the procedure that fills it.
|
|
13
|
+
|
|
14
|
+
1. Heartbeat immediately before any suite that can run longer than the lease, and again when that suite returns. A lease that lapses mid-suite requeues the item.
|
|
15
|
+
2. Run the SQLite suite. Then run the Postgres suite. Both database engines, in that order. A pass on one engine is not a pass. Record each as a `test` evidence entry with `passed`, `failed`, and `scope`. If the item's test scope is `agent_targeted`, run the targeted tests on both engines and leave the full suites to CI.
|
|
16
|
+
3. Break the caller on purpose: pass an empty argument, drop a field, or skip a branch. Run the test that should catch it. Record a `sabotage` receipt with `claim`, `mutation`, and `tests_failed`. `tests_failed` above zero means the test can fail. Zero means the test cannot fail, so fix the test and repeat. Revert the break before you commit.
|
|
17
|
+
4. Read the live surface the item names once: the endpoint, the page, the command output. Quote the sentence it returned in a `note` evidence entry. An empty list is a finding, not a pass: say so and do not hand it off as proven.
|
|
18
|
+
5. Commit, declare touchpoints, then set `review`.
|
|
19
|
+
|
|
20
|
+
The builder does not set `done`. Signing off is `graphban-review`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphban-ship
|
|
3
|
+
description: >
|
|
4
|
+
Open a pull request, rebase, or look at a salvage branch without guessing the
|
|
5
|
+
base. Use when opening a pull request, rebasing a branch, proposing a
|
|
6
|
+
change for review, or reading a salvage branch.
|
|
7
|
+
user-invocable: true
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Ship a branch
|
|
11
|
+
|
|
12
|
+
## Base
|
|
13
|
+
|
|
14
|
+
Call `get_context` and read `gitops`. Say which base rule applied:
|
|
15
|
+
|
|
16
|
+
1. the flag you were given,
|
|
17
|
+
2. a measured `base_branch`,
|
|
18
|
+
3. the remote default.
|
|
19
|
+
|
|
20
|
+
An unmeasured base is not `main`. When `gitops` reports the base as unmeasured, fall through to the remote default and say so. Do not guess.
|
|
21
|
+
|
|
22
|
+
## Subject
|
|
23
|
+
|
|
24
|
+
The subject is the item id, for example `GRPH-123: <title>`.
|
|
25
|
+
|
|
26
|
+
## Whitelist
|
|
27
|
+
|
|
28
|
+
The touchpoints snapshotted at wave start are the whitelist. A file outside them is why a proposal was withheld. Run `gbfleet check-touchpoints`, then declare the file with `update_item` or take it out of the diff. A branch that is behind the base is also withheld: rebase it first.
|
|
29
|
+
|
|
30
|
+
## Salvage
|
|
31
|
+
|
|
32
|
+
A salvage is a pushed branch and a note, with no pull URL. A salvage HEAD stays a branch: do not propose its commit as a pull request. A salvage pull request stays open when it is the only copy of the work. A salvage SHA is not signed off while CI is red.
|
|
33
|
+
|
|
34
|
+
## Done
|
|
35
|
+
|
|
36
|
+
Ledger `done` is not merged. Check the pull request state before saying the work landed.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphban-unstick
|
|
3
|
+
description: >
|
|
4
|
+
Name why a gbfleet drain is stuck and say what clears it. Use when `until`
|
|
5
|
+
exits review-unsigned, expires with nothing claimed, a child goes quiet, a
|
|
6
|
+
lease drops, or WAVE_IDLE shows while items are still next. Or when the user
|
|
7
|
+
runs /graphban-unstick.
|
|
8
|
+
user-invocable: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Unstick a drain
|
|
12
|
+
|
|
13
|
+
Name the item first. A stuck drain is one item, one holder, one timer. Run `fleet_status` and `get_item_details` on it before you do anything else.
|
|
14
|
+
|
|
15
|
+
## Which case
|
|
16
|
+
|
|
17
|
+
- `review-unsigned` — `until` exits because its review search is not limited to `--prd`. Say which item. A review row outside `--prd` is another PRD's work, not this wave's failure. A review claim has no TTL, so it stays until its reviewer signs off or releases it.
|
|
18
|
+
- Post-bounce pin — the bounced item stays pinned to its builder until the pin lapses. Wait. Do not reassign it.
|
|
19
|
+
- Dropped lease — a lease that lapsed is a timer. The item requeues when it runs out. Do not re-claim it for the child.
|
|
20
|
+
- Salvage note with no pull URL — the branch is the work. Find the branch and leave it. Do not delete or rebuild it.
|
|
21
|
+
- Live child with empty holdings — the child is running and holds nothing. That is not an idle wave, and `WAVE_IDLE` does not mean done while it keeps the drain open. Report it. Do not fix it from `watch-wave`.
|
|
22
|
+
- `WAVE_IDLE` while items are still `next` — a collision or a pin holds them. Name what holds them.
|
|
23
|
+
|
|
24
|
+
## Do not
|
|
25
|
+
|
|
26
|
+
Do not start a second `until` on the same repo. It mints another child onto the same cluster. Check `gbfleet service status --name <prd_id>` and leave the running one.
|
|
27
|
+
|
|
28
|
+
Do not `sign_off` the item. You did not review it.
|
|
29
|
+
|
|
30
|
+
Tell the user the item, the holder, and what clears it. Then stop.
|
|
@@ -64,3 +64,7 @@ Attach `graphban-answer` when the service reports running. It answers a `wait:de
|
|
|
64
64
|
`review-unsigned` can be a review row outside this PRD. Say which item. Do not start another drain over it, and do not `sign_off` it yourself.
|
|
65
65
|
|
|
66
66
|
`--merge` lands the pull requests this wave opened. Leave every other open pull request. A salvage branch can be the only copy of the work.
|
|
67
|
+
|
|
68
|
+
Before opening a pull request for a wave's work, read `graphban-ship`: it names the base rule, the touchpoint whitelist, and what a salvage is.
|
|
69
|
+
|
|
70
|
+
A drain that exits `review-unsigned`, or sits idle with items still `next`, is `graphban-unstick`.
|
|
@@ -168,7 +168,7 @@ INVOCATIONS = {
|
|
|
168
168
|
#: `setup` writes config files and so needs a repository and a target of its own
|
|
169
169
|
#: (`test_setup.py`, which makes the same route-documentation assertion this file does),
|
|
170
170
|
#: `swamp` is `test_swamp.py`, and `stack` is `test_stackmap.py`.
|
|
171
|
-
NOT_DRIVEN_HERE = {"login", "doctor", "fleet", "setup", "swamp", "stack"}
|
|
171
|
+
NOT_DRIVEN_HERE = {"login", "doctor", "fleet", "setup", "swamp", "stack", "update"}
|
|
172
172
|
|
|
173
173
|
|
|
174
174
|
def _documented() -> list[re.Pattern]:
|
|
@@ -32,6 +32,8 @@ class _Rec:
|
|
|
32
32
|
def _doctor(monkeypatch, lines, code, extra=()):
|
|
33
33
|
monkeypatch.setattr("gban.cli.doctor_mod.run", lambda *a, **k: (list(lines), code))
|
|
34
34
|
monkeypatch.setattr("gban.cli.doctor_mod.resolve_supervisor_key", lambda *a, **k: "")
|
|
35
|
+
# The hourly pack poll is its own line (test_playbooks); these tests are about the report.
|
|
36
|
+
monkeypatch.setattr("gban.cli._poll", lambda args: None)
|
|
35
37
|
return main(["doctor", *extra])
|
|
36
38
|
|
|
37
39
|
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from gban import cli, playbooks
|
|
8
|
+
from gban.client import Client, NoSession, Refused, Unreachable
|
|
9
|
+
|
|
10
|
+
URL = "http://x"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Fake:
|
|
14
|
+
def __init__(self, health, pack=None):
|
|
15
|
+
self.health, self.pack, self.calls = health, pack, []
|
|
16
|
+
|
|
17
|
+
def call(self, method, path, body=None):
|
|
18
|
+
self.calls.append(path)
|
|
19
|
+
res = self.health if path == "/health" else self.pack
|
|
20
|
+
if isinstance(res, Exception):
|
|
21
|
+
raise res
|
|
22
|
+
return res
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def poll(url, repo, *, client, **kw):
|
|
26
|
+
"""The Fake serves both halves unless a test hands a separate credentialed client."""
|
|
27
|
+
kw.setdefault("auth", lambda: client)
|
|
28
|
+
return playbooks.poll(url, repo, client=client, **kw)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sha(s):
|
|
32
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def receipt(tmp_path):
|
|
36
|
+
return json.loads((tmp_path / playbooks.RECEIPT).read_text())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_second_poll_within_hour_skips(tmp_path):
|
|
40
|
+
c = Fake({"git_sha": "a"}, {"git_sha": "a", "files": []})
|
|
41
|
+
poll(URL, tmp_path, client=c, now=1000)
|
|
42
|
+
r = poll(URL, tmp_path, client=c, now=1500)
|
|
43
|
+
assert c.calls == ["/health", "/api/playbooks"]
|
|
44
|
+
assert not r["polled"] and "last poll" in r["note"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_after_hour_and_other_server_and_force_poll(tmp_path):
|
|
48
|
+
c = Fake({"git_sha": "a"}, {"git_sha": "a", "files": []})
|
|
49
|
+
poll(URL, tmp_path, client=c, now=1000)
|
|
50
|
+
poll(URL, tmp_path, client=c, now=1000 + 3601)
|
|
51
|
+
assert c.calls.count("/health") == 2
|
|
52
|
+
poll("http://y", tmp_path, client=c, now=4700)
|
|
53
|
+
assert c.calls.count("/health") == 3
|
|
54
|
+
poll("http://y", tmp_path, client=c, now=4701, force=True)
|
|
55
|
+
assert c.calls.count("/health") == 4
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_dropped_connection_keeps_polled_at(tmp_path):
|
|
59
|
+
poll(URL, tmp_path, client=Fake(Unreachable("down")), now=1000)
|
|
60
|
+
assert not (tmp_path / playbooks.RECEIPT).exists()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unstamped_writes_no_file_sets_polled_at(tmp_path):
|
|
64
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": ""}), now=1000)
|
|
65
|
+
assert "unstamped" in r["note"] and receipt(tmp_path)["polled_at"] == 1000
|
|
66
|
+
assert receipt(tmp_path)["files"] == {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_404_pack(tmp_path):
|
|
70
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "a"}, Refused(404, "nf")), now=1000)
|
|
71
|
+
assert "no pack" in r["note"] and receipt(tmp_path)["polled_at"] == 1000
|
|
72
|
+
assert [p for p in tmp_path.rglob("*") if p.is_file()] == [tmp_path / playbooks.RECEIPT]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_same_sha_does_not_download(tmp_path):
|
|
76
|
+
c = Fake({"git_sha": "a"}, {"git_sha": "a", "files": []})
|
|
77
|
+
poll(URL, tmp_path, client=c, now=1000)
|
|
78
|
+
c.calls.clear()
|
|
79
|
+
poll(URL, tmp_path, client=c, now=1000 + 4000)
|
|
80
|
+
assert c.calls == ["/health"] and receipt(tmp_path)["polled_at"] == 5000
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_ownership(tmp_path):
|
|
84
|
+
def pack(sha_, body):
|
|
85
|
+
return {"git_sha": sha_, "files": [{"path": "s/a.md", "body": body},
|
|
86
|
+
{"path": "s/b.md", "body": "B"}]}
|
|
87
|
+
poll(URL, tmp_path, client=Fake({"git_sha": "1"}, pack("1", "A1")), now=0)
|
|
88
|
+
(tmp_path / "s/b.md").write_text("edited")
|
|
89
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "2"}, pack("2", "A2")), now=5000)
|
|
90
|
+
assert (tmp_path / "s/a.md").read_text() == "A2"
|
|
91
|
+
assert (tmp_path / "s/b.md").read_text() == "edited" and "s/b.md" in r["note"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_pack_is_fetched_with_the_credential_not_the_bare_client(tmp_path):
|
|
95
|
+
bare = Fake({"git_sha": "a"}, Refused(401, "authentication required"))
|
|
96
|
+
authed = Fake(None, {"git_sha": "a", "files": [{"path": "s/a.md", "body": "A"}]})
|
|
97
|
+
r = playbooks.poll(URL, tmp_path, client=bare, auth=lambda: authed, now=1000)
|
|
98
|
+
assert bare.calls == ["/health"] and authed.calls == ["/api/playbooks"]
|
|
99
|
+
assert (tmp_path / "s/a.md").read_text() == "A"
|
|
100
|
+
assert r["polled"] and receipt(tmp_path)["git_sha"] == "a"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_401_on_the_pack_leaves_polled_at_alone(tmp_path):
|
|
104
|
+
bare = Fake({"git_sha": "a"}, Refused(401, "authentication required"))
|
|
105
|
+
r = playbooks.poll(URL, tmp_path, client=bare, auth=lambda: bare, now=1000)
|
|
106
|
+
assert r.get("failed") and not (tmp_path / playbooks.RECEIPT).exists()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _no_session(*a, **k):
|
|
110
|
+
raise NoSession("no stored session")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_credentialed_falls_back_to_the_api_key(monkeypatch):
|
|
114
|
+
monkeypatch.setattr(playbooks, "authenticated", _no_session)
|
|
115
|
+
monkeypatch.setenv(playbooks.config.API_KEY_ENV, "gbk_env")
|
|
116
|
+
made = playbooks.credentialed(URL, "proj")
|
|
117
|
+
assert isinstance(made, Client) and made.api_key == "gbk_env" and not made.token
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_credentialed_uses_the_stored_project_key(monkeypatch):
|
|
121
|
+
monkeypatch.setattr(playbooks, "authenticated", _no_session)
|
|
122
|
+
monkeypatch.delenv(playbooks.config.API_KEY_ENV, raising=False)
|
|
123
|
+
monkeypatch.setattr(playbooks.config, "stored_supervisor_key",
|
|
124
|
+
lambda p: "gbk_stored" if p == "proj" else "")
|
|
125
|
+
assert playbooks.credentialed(URL, "proj").api_key == "gbk_stored"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_no_credential_is_a_failed_poll_not_a_crash(tmp_path, monkeypatch):
|
|
129
|
+
monkeypatch.setattr(playbooks, "authenticated", _no_session)
|
|
130
|
+
monkeypatch.delenv(playbooks.config.API_KEY_ENV, raising=False)
|
|
131
|
+
monkeypatch.setattr(playbooks.config, "stored_supervisor_key", lambda p: "")
|
|
132
|
+
r = playbooks.poll(URL, tmp_path, client=Fake({"git_sha": "a"}), now=1000)
|
|
133
|
+
assert r.get("failed") and "credential" in r["note"]
|
|
134
|
+
assert not (tmp_path / playbooks.RECEIPT).exists()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _git_repo(path):
|
|
138
|
+
subprocess.run(["git", "init", "-q", str(path)], check=True)
|
|
139
|
+
return path.resolve()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_checkout_is_the_git_toplevel(tmp_path):
|
|
143
|
+
root = _git_repo(tmp_path)
|
|
144
|
+
(root / "cli" / "src").mkdir(parents=True)
|
|
145
|
+
assert playbooks.checkout(root / "cli" / "src").resolve() == root
|
|
146
|
+
outside = tmp_path.parent / (tmp_path.name + "-bare")
|
|
147
|
+
outside.mkdir()
|
|
148
|
+
assert playbooks.checkout(outside) is None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_command_from_a_subdirectory_polls_the_checkout_root(tmp_path, monkeypatch):
|
|
152
|
+
root = _git_repo(tmp_path)
|
|
153
|
+
(root / "cli").mkdir()
|
|
154
|
+
monkeypatch.chdir(root / "cli")
|
|
155
|
+
seen = {}
|
|
156
|
+
|
|
157
|
+
def fake_poll(url, repo, **kw):
|
|
158
|
+
seen.update(url=url, repo=repo, **kw)
|
|
159
|
+
return {"note": "ok"}
|
|
160
|
+
monkeypatch.setattr(playbooks, "poll", fake_poll)
|
|
161
|
+
assert cli.main(["--server", URL, "update"]) == 0
|
|
162
|
+
assert seen["repo"].resolve() == root and seen["force"] is True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- GRPH-1493: applying the pack to the checkout -------------------------------------------
|
|
166
|
+
|
|
167
|
+
SKILL = ".claude/skills/graphban-work/SKILL.md"
|
|
168
|
+
COPIES = [SKILL, ".grok/skills/graphban-work/SKILL.md", ".agents/skills/graphban-work/SKILL.md"]
|
|
169
|
+
LOOP = ".claude/agents/gb-worker.md"
|
|
170
|
+
PLAYBOOK = ".cursor/plugins/graphban/skills/why-graphban/SKILL.md"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pack(sha_, skill="S1", loop="L1", playbook="P1", extra=()):
|
|
174
|
+
files = [{"path": SKILL, "kind": "skill", "body": skill},
|
|
175
|
+
{"path": LOOP, "kind": "loop", "body": loop},
|
|
176
|
+
{"path": PLAYBOOK, "kind": "playbook", "body": playbook}, *extra]
|
|
177
|
+
return {"git_sha": sha_, "files": files}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply(tmp_path, sha_, now, **kw):
|
|
181
|
+
return poll(URL, tmp_path, client=Fake({"git_sha": sha_}, _pack(sha_, **kw)), now=now)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_a_skill_is_written_to_all_three_skill_directories(tmp_path):
|
|
185
|
+
_apply(tmp_path, "1", 0)
|
|
186
|
+
for rel in COPIES:
|
|
187
|
+
assert (tmp_path / rel).read_text() == "S1"
|
|
188
|
+
assert receipt(tmp_path)["files"][rel] == sha("S1")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_changed_skill_loop_and_playbook_are_replaced(tmp_path):
|
|
192
|
+
_apply(tmp_path, "1", 0)
|
|
193
|
+
r = _apply(tmp_path, "2", 5000, skill="S2", loop="L2", playbook="P2")
|
|
194
|
+
for rel in COPIES:
|
|
195
|
+
assert (tmp_path / rel).read_text() == "S2"
|
|
196
|
+
assert (tmp_path / LOOP).read_text() == "L2"
|
|
197
|
+
assert (tmp_path / PLAYBOOK).read_text() == "P2"
|
|
198
|
+
assert sorted(r["written"]) == sorted([*COPIES, LOOP, PLAYBOOK])
|
|
199
|
+
assert receipt(tmp_path)["git_sha"] == "2"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_matching_bytes_are_not_rewritten(tmp_path):
|
|
203
|
+
_apply(tmp_path, "1", 0)
|
|
204
|
+
before = {rel: (tmp_path / rel).stat().st_mtime_ns for rel in COPIES}
|
|
205
|
+
r = _apply(tmp_path, "2", 5000, loop="L2")
|
|
206
|
+
assert r["written"] == [LOOP]
|
|
207
|
+
assert {rel: (tmp_path / rel).stat().st_mtime_ns for rel in COPIES} == before
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_a_committed_matching_copy_is_adopted_not_rewritten(tmp_path):
|
|
211
|
+
(tmp_path / SKILL).parent.mkdir(parents=True)
|
|
212
|
+
(tmp_path / SKILL).write_text("S1")
|
|
213
|
+
mtime = (tmp_path / SKILL).stat().st_mtime_ns
|
|
214
|
+
r = _apply(tmp_path, "1", 0)
|
|
215
|
+
assert SKILL not in r["written"] and (tmp_path / SKILL).stat().st_mtime_ns == mtime
|
|
216
|
+
assert receipt(tmp_path)["files"][SKILL] == sha("S1")
|
|
217
|
+
# Adopted, so the next pack replaces it.
|
|
218
|
+
_apply(tmp_path, "2", 5000, skill="S2")
|
|
219
|
+
assert (tmp_path / SKILL).read_text() == "S2"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_a_local_edit_is_left_and_named_while_the_rest_update(tmp_path):
|
|
223
|
+
_apply(tmp_path, "1", 0)
|
|
224
|
+
(tmp_path / LOOP).write_text("mine")
|
|
225
|
+
r = _apply(tmp_path, "2", 5000, skill="S2", loop="L2")
|
|
226
|
+
assert (tmp_path / LOOP).read_text() == "mine"
|
|
227
|
+
assert r["kept"] == [LOOP] and LOOP in r["note"]
|
|
228
|
+
assert (tmp_path / SKILL).read_text() == "S2"
|
|
229
|
+
# The edit stays the person's: the receipt still holds what we wrote, not their bytes.
|
|
230
|
+
assert receipt(tmp_path)["files"][LOOP] == sha("L1")
|
|
231
|
+
r = _apply(tmp_path, "3", 10000, skill="S3", loop="L3")
|
|
232
|
+
assert (tmp_path / LOOP).read_text() == "mine" and r["kept"] == [LOOP]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_an_uncommitted_differing_file_without_receipt_is_a_local_edit(tmp_path):
|
|
236
|
+
(tmp_path / LOOP).parent.mkdir(parents=True)
|
|
237
|
+
(tmp_path / LOOP).write_text("theirs")
|
|
238
|
+
r = _apply(tmp_path, "1", 0)
|
|
239
|
+
assert (tmp_path / LOOP).read_text() == "theirs" and LOOP in r["kept"]
|
|
240
|
+
assert LOOP not in receipt(tmp_path)["files"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_restoring_an_edit_is_delete_and_update(tmp_path):
|
|
244
|
+
_apply(tmp_path, "1", 0)
|
|
245
|
+
(tmp_path / LOOP).write_text("mine")
|
|
246
|
+
(tmp_path / LOOP).unlink()
|
|
247
|
+
r = _apply(tmp_path, "2", 5000)
|
|
248
|
+
assert (tmp_path / LOOP).read_text() == "L1" and LOOP in r["written"]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_a_path_the_pack_dropped_is_left_on_disk_and_named(tmp_path):
|
|
252
|
+
extra = [{"path": ".claude/agents/gb-old.md", "kind": "loop", "body": "old"}]
|
|
253
|
+
_apply(tmp_path, "1", 0, extra=extra)
|
|
254
|
+
r = _apply(tmp_path, "2", 5000)
|
|
255
|
+
assert (tmp_path / ".claude/agents/gb-old.md").read_text() == "old"
|
|
256
|
+
assert r["gone"] == [".claude/agents/gb-old.md"] and "gb-old.md" in r["note"]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_an_unreadable_entry_writes_nothing(tmp_path):
|
|
260
|
+
pack = _pack("1")
|
|
261
|
+
pack["files"].append({"path": "../escape.md", "kind": "loop", "body": "x"})
|
|
262
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "1"}, pack), now=0)
|
|
263
|
+
assert r.get("failed")
|
|
264
|
+
assert not any(p.is_file() for p in tmp_path.rglob("*"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_doctor_with_a_fresh_stamp_reports_the_stored_poll_not_a_check(tmp_path):
|
|
268
|
+
from gban import doctor
|
|
269
|
+
_apply(tmp_path, "abcdef1234567890", 1000)
|
|
270
|
+
c = Fake({"git_sha": "other"})
|
|
271
|
+
r = poll(URL, tmp_path, client=c, now=1500)
|
|
272
|
+
assert c.calls == []
|
|
273
|
+
line = doctor.playbooks_line(r)
|
|
274
|
+
assert line["status"] == doctor.UNKNOWN
|
|
275
|
+
assert playbooks.when(1000) in line["detail"] and "abcdef123456" in line["detail"]
|
|
276
|
+
assert "not checked" in line["detail"] and "just now" not in line["detail"]
|
|
277
|
+
fresh = doctor.playbooks_line(_apply(tmp_path, "b", 9000))
|
|
278
|
+
assert fresh["status"] == doctor.PASS and "just now" in fresh["detail"]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_gban_doctor_prints_the_playbooks_line(tmp_path, monkeypatch, capsys):
|
|
282
|
+
root = _git_repo(tmp_path)
|
|
283
|
+
monkeypatch.chdir(root)
|
|
284
|
+
monkeypatch.setattr(playbooks, "poll", lambda url, repo, **kw: {
|
|
285
|
+
"polled": False, "skipped": True, "polled_at": 1000, "git_sha": "abc",
|
|
286
|
+
"note": "last poll 1970-01-01 00:16:40Z at abc; not checked against the server"})
|
|
287
|
+
monkeypatch.setattr(cli.doctor_mod, "resolve_supervisor_key", lambda *a, **k: "")
|
|
288
|
+
monkeypatch.setattr(cli.doctor_mod, "run", lambda *a, **k: ([], 0))
|
|
289
|
+
assert cli.main(["--server", URL, "doctor", "--no-report"]) == 0
|
|
290
|
+
out = capsys.readouterr()
|
|
291
|
+
assert "playbooks" in out.out and "at abc" in out.out and "just now" not in out.out
|
|
292
|
+
assert "last poll" not in out.err
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --- GRPH-1506: hook files ride the pack, each to its own path -------------------------------
|
|
296
|
+
|
|
297
|
+
HOOK = ".cursor/hooks/gb_stop.py"
|
|
298
|
+
HOOKS_JSON = ".cursor/hooks.json"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _hook_pack(sha_, *extra):
|
|
302
|
+
files = [{"path": SKILL, "kind": "skill", "body": "S1"},
|
|
303
|
+
{"path": HOOK, "kind": "hook", "body": "H1"}, *extra]
|
|
304
|
+
return {"git_sha": sha_, "files": files}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_a_hook_is_written_once_at_its_own_path_and_a_skill_still_fans_out(tmp_path):
|
|
308
|
+
poll(URL, tmp_path, client=Fake({"git_sha": "1"}, _hook_pack("1")), now=0)
|
|
309
|
+
assert (tmp_path / HOOK).read_text() == "H1"
|
|
310
|
+
assert playbooks.targets({"path": HOOK, "kind": "hook"}) == [HOOK]
|
|
311
|
+
assert sorted(receipt(tmp_path)["files"]) == sorted([*COPIES, HOOK])
|
|
312
|
+
for rel in COPIES:
|
|
313
|
+
assert (tmp_path / rel).read_text() == "S1"
|
|
314
|
+
assert not (tmp_path / ".claude/hooks").exists()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_a_foreign_hooks_json_is_left_and_named_while_the_scripts_are_written(tmp_path):
|
|
318
|
+
(tmp_path / ".cursor").mkdir()
|
|
319
|
+
(tmp_path / HOOKS_JSON).write_text("theirs")
|
|
320
|
+
pack = _hook_pack("1", {"path": HOOKS_JSON, "kind": "hook", "body": "{}"})
|
|
321
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "1"}, pack), now=0)
|
|
322
|
+
assert (tmp_path / HOOKS_JSON).read_text() == "theirs"
|
|
323
|
+
assert HOOKS_JSON in r["kept"] and HOOKS_JSON in r["note"]
|
|
324
|
+
assert HOOKS_JSON not in receipt(tmp_path)["files"]
|
|
325
|
+
assert (tmp_path / HOOK).read_text() == "H1"
|
|
@@ -613,9 +613,8 @@ def test_the_skill_hands_the_person_a_runnable_line_and_carries_the_rest(tmp_pat
|
|
|
613
613
|
assert "do not retry" in body.lower()
|
|
614
614
|
|
|
615
615
|
|
|
616
|
-
def
|
|
617
|
-
"""
|
|
618
|
-
A skill that exists in the repo and not in the wheel installs nothing for anyone."""
|
|
616
|
+
def _built_wheel_names(tmp_path):
|
|
617
|
+
"""Build the CLI wheel and return its member names; skip when no builder exists."""
|
|
619
618
|
import subprocess
|
|
620
619
|
import sys
|
|
621
620
|
import zipfile
|
|
@@ -634,12 +633,19 @@ def test_the_skill_ships_in_the_wheel(tmp_path):
|
|
|
634
633
|
if done is None or done.returncode != 0:
|
|
635
634
|
pytest.skip("no wheel builder available")
|
|
636
635
|
wheel = next(out.glob("*.whl"))
|
|
637
|
-
|
|
636
|
+
return zipfile.ZipFile(wheel).namelist()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def test_the_skill_ships_in_the_wheel(tmp_path):
|
|
640
|
+
"""Package DATA, not a .py file — the one class of file a packaging config silently drops.
|
|
641
|
+
A skill that exists in the repo and not in the wheel installs nothing for anyone."""
|
|
642
|
+
names = _built_wheel_names(tmp_path)
|
|
638
643
|
assert any(n.endswith("skills/graphban-delegation/SKILL.md") for n in names), names[:20]
|
|
639
644
|
assert any(n.endswith("skills/watch-wave/SKILL.md") for n in names), names[:20]
|
|
640
645
|
assert any(n.endswith("skills/graphban-grill/SKILL.md") for n in names), names[:20]
|
|
641
646
|
assert any(n.endswith("skills/graphban-wave/SKILL.md") for n in names), names[:20]
|
|
642
647
|
assert any(n.endswith("skills/graphban-answer/SKILL.md") for n in names), names[:20]
|
|
648
|
+
assert any(n.endswith("skills/graphban-prove/SKILL.md") for n in names), names[:20]
|
|
643
649
|
|
|
644
650
|
|
|
645
651
|
def test_graphban_wave_drains_one_prd_and_does_not_file_tasks():
|
|
@@ -656,6 +662,19 @@ def test_graphban_wave_drains_one_prd_and_does_not_file_tasks():
|
|
|
656
662
|
assert "Do not attach a spawn watcher" in body
|
|
657
663
|
|
|
658
664
|
|
|
665
|
+
def test_graphban_ship_names_base_salvage_and_done(tmp_path):
|
|
666
|
+
body = " ".join(setup_mod.skill_source("graphban-ship").read_text(encoding="utf-8").split())
|
|
667
|
+
assert "get_context" in body
|
|
668
|
+
assert "unmeasured" in body
|
|
669
|
+
assert "whitelist" in body
|
|
670
|
+
assert "no pull URL" in body
|
|
671
|
+
assert "is not merged" in body
|
|
672
|
+
wave = setup_mod.skill_source("graphban-wave").read_text(encoding="utf-8")
|
|
673
|
+
assert "graphban-ship" in wave
|
|
674
|
+
names = _built_wheel_names(tmp_path)
|
|
675
|
+
assert any(n.endswith("skills/graphban-ship/SKILL.md") for n in names), names[:20]
|
|
676
|
+
|
|
677
|
+
|
|
659
678
|
def test_watch_wave_names_the_scheduler_and_cancels_on_idle():
|
|
660
679
|
"""The Grok procedure has to live in the shipped skill, not only in a laptop
|
|
661
680
|
copy under ~/.grok. A skill that never says WAVE_IDLE leaves the loop running
|
|
@@ -1363,6 +1382,41 @@ def test_the_old_subcommand_flag_order_is_still_rejected():
|
|
|
1363
1382
|
_parser().parse_args(argv)
|
|
1364
1383
|
|
|
1365
1384
|
|
|
1385
|
+
def test_graphban_prove_states_the_finish_bar(tmp_path):
|
|
1386
|
+
"""The finish bar lives in the shipped skill: heartbeat around the long suite, both
|
|
1387
|
+
engines, a sabotage receipt that failed a test, a quoted live read, no builder `done`."""
|
|
1388
|
+
body = " ".join(setup_mod.skill_source("graphban-prove").read_text(encoding="utf-8").split())
|
|
1389
|
+
assert "Heartbeat immediately before" in body
|
|
1390
|
+
assert "and again when that suite returns" in body
|
|
1391
|
+
assert "SQLite suite" in body
|
|
1392
|
+
assert "Postgres suite" in body
|
|
1393
|
+
assert "`sabotage` receipt" in body
|
|
1394
|
+
assert "`tests_failed`" in body
|
|
1395
|
+
assert "above zero" in body
|
|
1396
|
+
assert "Quote the sentence" in body
|
|
1397
|
+
assert "An empty list is a finding" in body
|
|
1398
|
+
assert "The builder does not set `done`" in body
|
|
1399
|
+
names = _built_wheel_names(tmp_path)
|
|
1400
|
+
assert any(n.endswith("skills/graphban-prove/SKILL.md") for n in names), names[:20]
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def test_graphban_unstick_names_the_stuck_drain(tmp_path):
|
|
1404
|
+
"""The unstick skill names the item, the review row outside --prd, the lease
|
|
1405
|
+
timer and the empty-holdings child, and never starts a second until or signs off."""
|
|
1406
|
+
body = " ".join(setup_mod.skill_source("graphban-unstick").read_text(encoding="utf-8").split())
|
|
1407
|
+
assert "review-unsigned" in body
|
|
1408
|
+
assert "review row outside `--prd`" in body
|
|
1409
|
+
assert "Dropped lease" in body
|
|
1410
|
+
assert "empty holdings" in body
|
|
1411
|
+
assert "Do not start a second `until` on the same repo" in body
|
|
1412
|
+
assert "Do not `sign_off` the item" in body
|
|
1413
|
+
for slug in ("graphban-wave", "watch-wave"):
|
|
1414
|
+
other = " ".join(setup_mod.skill_source(slug).read_text(encoding="utf-8").split())
|
|
1415
|
+
assert "graphban-unstick" in other, slug
|
|
1416
|
+
names = _built_wheel_names(tmp_path)
|
|
1417
|
+
assert any(n.endswith("skills/graphban-unstick/SKILL.md") for n in names), names[:20]
|
|
1418
|
+
|
|
1419
|
+
|
|
1366
1420
|
def test_graphban_work_names_the_lease_the_graph_and_prove():
|
|
1367
1421
|
"""The work skill must warn that a silent long command drops the lease, send
|
|
1368
1422
|
changed paths to describe_code, call a receipt a test line, and hand
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|