graphban-cli 0.8.6__tar.gz → 0.8.7__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.
Files changed (42) hide show
  1. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/.gitignore +1 -0
  2. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/PKG-INFO +1 -1
  3. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/pyproject.toml +1 -1
  4. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/cli.py +39 -2
  5. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/doctor.py +16 -0
  6. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/gitignore.py +1 -0
  7. graphban_cli-0.8.7/src/gban/playbooks.py +217 -0
  8. graphban_cli-0.8.7/src/gban/skills/graphban-prove/SKILL.md +20 -0
  9. graphban_cli-0.8.7/src/gban/skills/graphban-ship/SKILL.md +36 -0
  10. graphban_cli-0.8.7/src/gban/skills/graphban-unstick/SKILL.md +30 -0
  11. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-wave/SKILL.md +4 -0
  12. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/watch-wave/SKILL.md +2 -0
  13. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_acts.py +1 -1
  14. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_machine_report.py +2 -0
  15. graphban_cli-0.8.7/tests/test_playbooks.py +292 -0
  16. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_setup.py +58 -4
  17. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/uv.lock +1 -1
  18. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/LICENSE +0 -0
  19. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/README.md +0 -0
  20. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/__init__.py +0 -0
  21. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/client.py +0 -0
  22. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/config.py +0 -0
  23. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/data/stack_sdks.toml +0 -0
  24. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/machine.py +0 -0
  25. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/setup.py +0 -0
  26. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-answer/SKILL.md +0 -0
  27. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
  28. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-grill/SKILL.md +0 -0
  29. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-orient/SKILL.md +0 -0
  30. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-prd/SKILL.md +0 -0
  31. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-review/SKILL.md +0 -0
  32. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-scan/SKILL.md +0 -0
  33. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/skills/graphban-work/SKILL.md +0 -0
  34. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/stackmap.py +0 -0
  35. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/src/gban/swamp.py +0 -0
  36. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_doctor.py +0 -0
  37. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_login_pair.py +0 -0
  38. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_login_projects.py +0 -0
  39. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_packaging.py +0 -0
  40. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_session.py +0 -0
  41. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_stackmap.py +0 -0
  42. {graphban_cli-0.8.6 → graphban_cli-0.8.7}/tests/test_swamp.py +0 -0
@@ -30,6 +30,7 @@ fleet/.pytest_cache/
30
30
  # never committed: a CI push to protected main is refused and turns main red (GRPH-1185).
31
31
  backend/app/data/refusal_coverage.json
32
32
  backend/app/data/gban_skills.json
33
+ backend/app/data/playbooks.json
33
34
 
34
35
  # Node
35
36
  web/node_modules/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.8.6
3
+ Version: 0.8.7
4
4
  Summary: gban — a Graphban client for the human at a terminal
5
5
  Project-URL: Homepage, https://github.com/asc-me/graphban
6
6
  Project-URL: Repository, https://github.com/asc-me/graphban
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.8.6"
3
+ version = "0.8.7"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
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)
@@ -36,6 +36,7 @@ PATTERNS = (
36
36
  ".gbfleet-*",
37
37
  ".swamp/",
38
38
  "graphban-swamp/",
39
+ ".graphban/playbooks.json",
39
40
  )
40
41
 
41
42
  #: What we ASK git about. A glob is not a path; these are the files that would be
@@ -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`.
@@ -73,3 +73,5 @@ Post the report. Then:
73
73
  wave (attach a new loop, delete the old), or on `WAVE_IDLE`.
74
74
 
75
75
  Do not leave a loop running on an idle wave.
76
+
77
+ A quiet child, a dropped lease, or an idle wave with items still `next` is reported here and cleared by `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,292 @@
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
@@ -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 test_the_skill_ships_in_the_wheel(tmp_path):
617
- """Package DATA, not a .py file — the one class of file a packaging config silently drops.
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
- names = zipfile.ZipFile(wheel).namelist()
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
@@ -124,7 +124,7 @@ wheels = [
124
124
 
125
125
  [[package]]
126
126
  name = "graphban-cli"
127
- version = "0.8.6"
127
+ version = "0.8.7"
128
128
  source = { editable = "." }
129
129
 
130
130
  [package.optional-dependencies]
File without changes
File without changes