graphban-cli 0.8.4__tar.gz → 0.8.6__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 (37) hide show
  1. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/PKG-INFO +12 -2
  2. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/README.md +11 -1
  3. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/pyproject.toml +1 -1
  4. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/cli.py +53 -4
  5. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/client.py +9 -0
  6. graphban_cli-0.8.6/src/gban/machine.py +91 -0
  7. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/setup.py +46 -1
  8. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-work/SKILL.md +7 -1
  9. graphban_cli-0.8.6/tests/test_login_pair.py +46 -0
  10. graphban_cli-0.8.6/tests/test_machine_report.py +81 -0
  11. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_setup.py +126 -0
  12. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/uv.lock +1 -1
  13. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/.gitignore +0 -0
  14. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/LICENSE +0 -0
  15. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/__init__.py +0 -0
  16. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/config.py +0 -0
  17. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/data/stack_sdks.toml +0 -0
  18. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/doctor.py +0 -0
  19. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/gitignore.py +0 -0
  20. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-answer/SKILL.md +0 -0
  21. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
  22. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-grill/SKILL.md +0 -0
  23. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-orient/SKILL.md +0 -0
  24. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-prd/SKILL.md +0 -0
  25. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-review/SKILL.md +0 -0
  26. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-scan/SKILL.md +0 -0
  27. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/graphban-wave/SKILL.md +0 -0
  28. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/skills/watch-wave/SKILL.md +0 -0
  29. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/stackmap.py +0 -0
  30. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/src/gban/swamp.py +0 -0
  31. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_acts.py +0 -0
  32. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_doctor.py +0 -0
  33. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_login_projects.py +0 -0
  34. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_packaging.py +0 -0
  35. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_session.py +0 -0
  36. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_stackmap.py +0 -0
  37. {graphban_cli-0.8.4 → graphban_cli-0.8.6}/tests/test_swamp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.8.4
3
+ Version: 0.8.6
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
@@ -87,6 +87,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
87
87
  ```bash
88
88
  gban login --server https://cloud.agentldgr.dev
89
89
  gban doctor # both halves: the ledger, and the local fleet
90
+ # (also reports the result to the server; --no-report opts out)
90
91
  gban agents # the roster, and why an agent is stuck
91
92
  gban agents role SA-A4 planner # what used to need a browser
92
93
  gban seats issue worker worker planner # one entry per agent
@@ -171,6 +172,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
171
172
  committed. It asks git (`check-ignore`), so an equivalent pattern already in the
172
173
  file is left alone.
173
174
 
175
+ ## Machine reports
176
+
177
+ `gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
178
+ with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
179
+ machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
180
+ under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
181
+ the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
182
+ its own checks — an undelivered report never counts as proof.
183
+
174
184
  ## Wiring a checkout to Swamp
175
185
 
176
186
  ```bash
@@ -221,7 +231,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
221
231
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
222
232
  read when it cannot turn echo off — it warns, but the warning arrives after the person has
223
233
  decided to type — so a login through a pipe, a heredoc or an editor's command runner would
224
- put the password in the scrollback. There is no non-interactive login yet (PRD-40 open
234
+ put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
225
235
  question 2: an API key cannot reach the JWT routes, so CI would need a service session).
226
236
 
227
237
  ## Why not `gb`
@@ -72,6 +72,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
72
72
  ```bash
73
73
  gban login --server https://cloud.agentldgr.dev
74
74
  gban doctor # both halves: the ledger, and the local fleet
75
+ # (also reports the result to the server; --no-report opts out)
75
76
  gban agents # the roster, and why an agent is stuck
76
77
  gban agents role SA-A4 planner # what used to need a browser
77
78
  gban seats issue worker worker planner # one entry per agent
@@ -156,6 +157,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
156
157
  committed. It asks git (`check-ignore`), so an equivalent pattern already in the
157
158
  file is left alone.
158
159
 
160
+ ## Machine reports
161
+
162
+ `gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
163
+ with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
164
+ machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
165
+ under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
166
+ the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
167
+ its own checks — an undelivered report never counts as proof.
168
+
159
169
  ## Wiring a checkout to Swamp
160
170
 
161
171
  ```bash
@@ -206,7 +216,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
206
216
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
207
217
  read when it cannot turn echo off — it warns, but the warning arrives after the person has
208
218
  decided to type — so a login through a pipe, a heredoc or an editor's command runner would
209
- put the password in the scrollback. There is no non-interactive login yet (PRD-40 open
219
+ put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
210
220
  question 2: an API key cannot reach the JWT routes, so CI would need a service session).
211
221
 
212
222
  ## Why not `gb`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.8.4"
3
+ version = "0.8.6"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -13,12 +13,14 @@ import os
13
13
  import shutil
14
14
  import subprocess
15
15
  import sys
16
+ import time
16
17
  from pathlib import Path
17
18
 
18
- from gban import config, doctor as doctor_mod, setup as setup_mod, stackmap, swamp as swamp_mod
19
+ from gban import config, doctor as doctor_mod, machine as machine_mod, setup as setup_mod, stackmap, swamp as swamp_mod
19
20
  from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
20
21
  Client, NoSession,
21
- Refused, Unreachable, authenticated, login)
22
+ Refused, Unreachable, authenticated, login, pair_poll,
23
+ pair_start)
22
24
 
23
25
  PROG = "gban"
24
26
 
@@ -44,14 +46,20 @@ def _parser() -> argparse.ArgumentParser:
44
46
  "written to session.json at mode 600; the access token is never stored, "
45
47
  "because it is renewed on every invocation anyway."))
46
48
  login_cmd.add_argument("--email", default=None)
49
+ login_cmd.add_argument(
50
+ "--pair", action="store_true",
51
+ help=("sign in without typing a password here: prints a short code that a signed-in "
52
+ "person approves in the web app's quick-start dialog"))
47
53
 
48
54
  sub.add_parser("logout", help="end the session, here and on the server")
49
- sub.add_parser(
55
+ doctor_cmd = sub.add_parser(
50
56
  "doctor", help="check both halves: the ledger, and the local fleet",
51
57
  description=("Everything that can be checked before anything is spawned. The ledger "
52
58
  "half runs here; the local half is `gbfleet doctor`, run as a subprocess. "
53
59
  "Neither half silences the other: what could not be checked prints "
54
60
  "UNKNOWN with its reason, never a pass."))
61
+ doctor_cmd.add_argument("--no-report", action="store_true",
62
+ help="do not send the result to the server (Settings → Machines)")
55
63
 
56
64
  setup_cmd = sub.add_parser(
57
65
  "setup", help="enable delegation on a project",
@@ -75,6 +83,8 @@ def _parser() -> argparse.ArgumentParser:
75
83
  "lookup")
76
84
  setup_cmd.add_argument("--no-install", action="store_true",
77
85
  help="do not install the supervisor")
86
+ setup_cmd.add_argument("--no-report", action="store_true",
87
+ help="do not send the result to the server (Settings → Machines)")
78
88
 
79
89
  swamp_cmd = sub.add_parser(
80
90
  "swamp", help="wire this checkout to Swamp's Graphban adapter",
@@ -183,6 +193,8 @@ def _server(args) -> str:
183
193
 
184
194
  def cmd_login(args) -> int:
185
195
  url = _server(args)
196
+ if args.pair:
197
+ return _login_pair(args, url)
186
198
  # A TERMINAL, OR NOTHING. `getpass` falls back to a plain echoing read when it cannot
187
199
  # turn echo off, and warns about it — which is the wrong trade for a password: the
188
200
  # warning arrives after the person has already decided to type. Without a tty the
@@ -205,7 +217,38 @@ def cmd_login(args) -> int:
205
217
  # same rule criterion 4 applies to an expired session applies to a cancelled login.
206
218
  print(f"\n{PROG}: cancelled", file=sys.stderr)
207
219
  return EXIT_REFUSED
208
- pair = login(url, email, password)
220
+ return _finish_login(args, url, email, login(url, email, password))
221
+
222
+
223
+ def _login_pair(args, url: str) -> int:
224
+ """Device-code sign-in (GRPH-1366): the identity is whoever approves the code in the app."""
225
+ started = pair_start(url)
226
+ code = started.get("user_code") or ""
227
+ print(f"{PROG}: open the quick-start dialog in {url} (step 3) and enter this code:\n\n"
228
+ f" {code}\n\n waiting for approval (expires in "
229
+ f"{int(started.get('expires_in', 0)) // 60} min)…", file=sys.stderr)
230
+ deadline = time.monotonic() + float(started.get("expires_in", 600))
231
+ interval = max(float(started.get("interval", 3)), 0.0)
232
+ try:
233
+ while time.monotonic() < deadline:
234
+ time.sleep(interval)
235
+ try:
236
+ got = pair_poll(url, started.get("device_code", ""))
237
+ except Refused:
238
+ print(f"{PROG}: the code expired or was already used. Run `{PROG} login --pair` "
239
+ f"again.", file=sys.stderr)
240
+ return EXIT_REFUSED
241
+ if got.get("status") == "approved":
242
+ return _finish_login(args, url, got.get("email") or "", got)
243
+ except KeyboardInterrupt:
244
+ print(f"\n{PROG}: cancelled", file=sys.stderr)
245
+ return EXIT_REFUSED
246
+ print(f"{PROG}: the code expired before anyone approved it. Run `{PROG} login --pair` "
247
+ f"again.", file=sys.stderr)
248
+ return EXIT_REFUSED
249
+
250
+
251
+ def _finish_login(args, url: str, email: str, pair: dict) -> int:
209
252
  refresh = pair.get("refresh_token") or ""
210
253
  if not refresh:
211
254
  print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
@@ -327,6 +370,8 @@ def cmd_doctor(args) -> int:
327
370
  project = config.resolve(args.project, config.PROJECT_ENV, "project")
328
371
  api_key = doctor_mod.resolve_supervisor_key(project, url=url)
329
372
  lines, code = doctor_mod.run(url, project, api_key)
373
+ if not getattr(args, "no_report", False):
374
+ lines += machine_mod.send(url, project, lines) # UNKNOWN when undelivered: exit unchanged
330
375
  _out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
331
376
  return code
332
377
 
@@ -354,6 +399,8 @@ def cmd_setup(args) -> int:
354
399
  return EXIT_REFUSED
355
400
  lines, code, made = setup_mod.run(client, url, project, Path.cwd(), scope=args.scope,
356
401
  install=not args.no_install)
402
+ if not args.no_report:
403
+ lines += machine_mod.send(url, project, lines)
357
404
  human = [doctor_mod.render(lines)]
358
405
  if how != "named":
359
406
  human.insert(0, f"{PROG}: {project} — {how}")
@@ -388,6 +435,8 @@ def _setup_auto(args, url: str, client) -> int:
388
435
  got, code, made = setup_mod.run(client, url, pid, repo, scope=args.scope,
389
436
  install=not args.no_install)
390
437
  lines += [{**l, "name": f"{pid}: {l['name']}"} for l in got]
438
+ if not args.no_report:
439
+ lines += [{**l, "name": f"{pid}: {l['name']}"} for l in machine_mod.send(url, pid, got)]
391
440
  done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
392
441
  human = [doctor_mod.render(lines),
393
442
  f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
@@ -135,6 +135,15 @@ def login(url: str, email: str, password: str) -> dict:
135
135
  {"email": email, "password": password})
136
136
 
137
137
 
138
+ def pair_start(url: str) -> dict:
139
+ """Begin a device-code sign-in: no password, no credential yet (GRPH-1366)."""
140
+ return Client(url).call("POST", "/api/cli/pair")
141
+
142
+
143
+ def pair_poll(url: str, device_code: str) -> dict:
144
+ return Client(url).call("POST", "/api/cli/pair/token", {"device_code": device_code})
145
+
146
+
138
147
  def authenticated(url: str, *, act: str = "") -> Client:
139
148
  """A client for this invocation: one refresh exchange, access token held in memory.
140
149
 
@@ -0,0 +1,91 @@
1
+ """Machine identity and the report `gban setup` / `gban doctor` send (GRPH-1442).
2
+
3
+ The machine id is random and lives in `machine.json` in gban's home. Wiping that directory makes
4
+ a new machine, which is the intent: the id names an installation, not a person or a label.
5
+
6
+ A report that cannot be delivered NEVER counts. It comes back as an UNKNOWN line naming the
7
+ server and the reason, and the caller still exits on its own checks.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import shutil
13
+ import socket
14
+ import subprocess # nosec B404 - fixed argv, no shell
15
+ import uuid
16
+ from importlib import metadata
17
+
18
+ from gban import config
19
+ from gban.client import NoSession, Refused, Unreachable, authenticated
20
+ from gban.doctor import UNKNOWN, _line
21
+
22
+ MACHINE_FILE = "machine.json"
23
+
24
+
25
+ def machine_id() -> str:
26
+ """The stored id, minted on first use. An unwritable home still yields a (volatile) id."""
27
+ path = config.home() / MACHINE_FILE
28
+ existing = config._read(path).get("machine_id")
29
+ if isinstance(existing, str) and existing:
30
+ return existing
31
+ fresh = "gbm_" + uuid.uuid4().hex
32
+ try:
33
+ config._write(path, {"machine_id": fresh})
34
+ except OSError:
35
+ pass
36
+ return fresh
37
+
38
+
39
+ def _version(dist: str) -> str:
40
+ try:
41
+ return metadata.version(dist)
42
+ except metadata.PackageNotFoundError:
43
+ return ""
44
+
45
+
46
+ def _gbfleet_version() -> str:
47
+ exe = shutil.which("gbfleet")
48
+ if not exe:
49
+ return ""
50
+ try:
51
+ done = subprocess.run([exe, "--version"], capture_output=True, text=True, # nosec B603
52
+ timeout=5)
53
+ return (done.stdout or "").strip().split()[-1] if done.returncode == 0 and done.stdout.strip() else ""
54
+ except (OSError, subprocess.SubprocessError):
55
+ return ""
56
+
57
+
58
+ def _harnesses() -> list[str]:
59
+ return [name for name, binary in (("claude", "claude"), ("grok", "grok"), ("codex", "codex"))
60
+ if shutil.which(binary)]
61
+
62
+
63
+ def payload(project: str, lines: list[dict]) -> dict:
64
+ return {
65
+ "machine_id": machine_id(), "label": socket.gethostname().split(".")[0],
66
+ "os": f"{platform.system()} {platform.release()}".strip(),
67
+ "gban_version": _version("gban"), "gbfleet_version": _gbfleet_version(),
68
+ "harnesses": _harnesses(), "project": project,
69
+ "lines": [{"status": l["status"], "name": l["name"], "detail": l.get("detail", "")}
70
+ for l in lines],
71
+ }
72
+
73
+
74
+ def send(url: str, project: str, lines: list[dict]) -> list[dict]:
75
+ """Deliver the report. Returns the line to append when it could not be delivered, else []."""
76
+ try:
77
+ authenticated(url, act="report").call("POST", "/api/machines/report",
78
+ payload(project, lines))
79
+ except NoSession as exc:
80
+ return [_unreported(url, exc.why)]
81
+ except Refused as exc:
82
+ return [_unreported(url, exc.detail)]
83
+ except Unreachable as exc:
84
+ return [_unreported(url, str(exc))]
85
+ except Exception as exc: # noqa: BLE001 - a report must never take the command down
86
+ return [_unreported(url, f"{type(exc).__name__}: {exc}")]
87
+ return []
88
+
89
+
90
+ def _unreported(url: str, reason: str) -> dict:
91
+ return _line("report", UNKNOWN, "report", f"could not report to {url}: {reason}")
@@ -62,8 +62,10 @@ SETUP_AGENTS = ("claude", "grok")
62
62
  #: flag that moves (e.g. `--server` is a top-level option, not a `login` one) fails a test
63
63
  #: rather than a person's terminal.
64
64
  QUICKSTART_COMMANDS = (
65
- "uv tool install graphban-cli",
65
+ "uv tool install graphban-cli && uv tool install graphban-fleet",
66
66
  "gban --server {server} login",
67
+ "gban --server {server} login --pair",
68
+ "gban setup",
67
69
  "gban --project {project} setup",
68
70
  )
69
71
 
@@ -770,6 +772,40 @@ def match(projects: list[dict], here: Path) -> tuple[dict, list[dict]]:
770
772
  return by_project, notes
771
773
 
772
774
 
775
+ def normalize_remote(raw: str) -> str:
776
+ """`host/owner/name`, lowercased, no scheme, credentials, port or `.git`. The same rule as
777
+ backend/app/services/projects.py:normalize_repo_url (a separate distribution cannot import
778
+ it); tests on both sides pin the same table. "" when it does not look like a remote."""
779
+ value = (raw or "").strip()
780
+ m = re.match(r"^(?:[a-z][a-z0-9+.-]*://(?:[^@/]*@)?([^/:]+)(?::\d*)?|(?:[^@/]*@)?([^/:]+):?)/?(.+)$",
781
+ value, re.IGNORECASE)
782
+ if not m or any(ch.isspace() for ch in value):
783
+ return ""
784
+ path = m.group(3).strip("/")
785
+ path = path[:-4] if path.endswith(".git") else path
786
+ return f"{m.group(1) or m.group(2)}/{path}".lower() if "/" in path else ""
787
+
788
+
789
+ def git_remotes(here: Path) -> list[str]:
790
+ """Normalized URLs of every remote of the checkout at `here`, `origin` first. [] when it is
791
+ not a checkout or git is absent — which is "no signal", and falls through to the name match."""
792
+ try:
793
+ names_out = subprocess.run(["git", "-C", str(here), "remote"], capture_output=True,
794
+ text=True, timeout=10)
795
+ if names_out.returncode != 0:
796
+ return []
797
+ out = []
798
+ for name in sorted(names_out.stdout.split(), key=lambda n: n != "origin"):
799
+ got = subprocess.run(["git", "-C", str(here), "remote", "get-url", name],
800
+ capture_output=True, text=True, timeout=10)
801
+ norm = normalize_remote(got.stdout) if got.returncode == 0 else ""
802
+ if norm and norm not in out:
803
+ out.append(norm)
804
+ return out
805
+ except (OSError, subprocess.SubprocessError):
806
+ return []
807
+
808
+
773
809
  class Unresolved(Exception):
774
810
  """The directory does not name a project on this deployment (GRPH-795)."""
775
811
 
@@ -792,6 +828,15 @@ def resolve_project(client, here: Path, asked: str = "", stored: str = "") -> tu
792
828
  return asked, "named"
793
829
  rows = client.call("GET", "/api/projects")
794
830
  projects = [p for p in (rows if isinstance(rows, list) else []) if isinstance(p, dict)]
831
+ # The git remote is the stronger signal: a directory can be renamed, a remote names the repo.
832
+ remotes = git_remotes(here)
833
+ by_remote = [p for p in projects if str(p.get("repo_url") or "") in remotes]
834
+ if len(by_remote) == 1:
835
+ return str(by_remote[0].get("id") or ""), f"matched the git remote {by_remote[0].get('repo_url')!r}"
836
+ if len(by_remote) > 1:
837
+ raise Unresolved(
838
+ f"this checkout's remote matches several projects "
839
+ f"({', '.join(sorted(str(p.get('id')) for p in by_remote))}), so none was chosen — name one with --project")
795
840
  hits = [p for p in projects if slug(here.name) in names(p)]
796
841
  if len(hits) == 1:
797
842
  return str(hits[0].get("id") or ""), f"matched the directory name {here.name!r}"
@@ -15,6 +15,10 @@ Take one item, leave a receipt, hand it to review. Reading the repo is `graphban
15
15
  2. Fleet: `claim_cluster`. Solo: `claim_next`. `claimed: false` names who holds the area and when it frees. That is the answer. Take different work.
16
16
  3. `get_item_details` once. The `brief` suggests `lane` and `tier` with a basis. You still choose both if you hand it to a child.
17
17
  4. `heartbeat(id, status=<one line>, files=[the paths you are in])` on the lease interval. A heartbeat is not a `fleet_status` poll.
18
+ Heartbeat before any command that can run longer than the lease: a Postgres suite or `pnpm test` prints nothing, and a silent command drops the claim around 150 seconds.
19
+ After you change paths, `describe_code` those paths so the next child does not grep for them.
20
+ A review-ready receipt is a test line: an `update_item` evidence entry of kind `test` with passed and failed counts.
21
+ Finishing the item is `graphban-prove`.
18
22
  5. `update_item` as you go. Evidence appends. Touchpoints union: send the files you actually touched. An empty touchpoint list writes nothing. Status goes to `review`.
19
23
  6. Stopping unfinished: `release_item`. A blocker that is still true tomorrow is status `blocked` with `blocker` set. `backlog` is claimable and will be handed out again. A build claim released without `to_status` goes back to `next`.
20
24
 
@@ -22,7 +26,9 @@ Test scope follows the project's mode, which your delegation brief states. `agen
22
26
 
23
27
  A product decision the item and its PRD framing do not already state: file a wait and stop. `create_item` with tag `wait:decision`, `status=blocked`, this item's `prd_id`, and a description that is the question plus what each answer would make you do. `link_items` with `a` this item, `b` the wait, `type` `dependency`, `reason` `wait:decision`. Leave this item `blocked`. Leave the wait blocked. Answering it is `graphban-answer`.
24
28
 
25
- A broken build stays a free-text `blocker` on this item. That is not a wait.
29
+ A broken build is either still this item (move it back to `next` with the failure named), or a
30
+ new ticket this item depends on — `create_item`, `link_items` with `type=dependency`, and
31
+ `blocker` set to that item's id. A free-text `blocker` alone is not a wait and is not claimable.
26
32
 
27
33
  Signing off is `graphban-review`. The builder does not set `done`.
28
34
 
@@ -0,0 +1,46 @@
1
+ """`gban login --pair` signs in without a password prompt or a tty (GRPH-1366)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from gban import cli as cli_mod, config
7
+ from gban.cli import main
8
+ from gban.client import Refused
9
+
10
+
11
+ @pytest.fixture()
12
+ def home(tmp_path, monkeypatch):
13
+ monkeypatch.setenv(config.HOME_ENV, str(tmp_path))
14
+ for var in (config.URL_ENV, config.PROJECT_ENV, config.API_KEY_ENV):
15
+ monkeypatch.delenv(var, raising=False)
16
+ monkeypatch.setattr(cli_mod.time, "sleep", lambda *_: None)
17
+ monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False)
18
+ monkeypatch.setattr(cli_mod, "authenticated", lambda url, act="": type(
19
+ "C", (), {"call": lambda self, *a, **k: []})())
20
+ return tmp_path
21
+
22
+
23
+ START = {"device_code": "dev", "user_code": "ABCD-EFGH", "expires_in": 600, "interval": 0}
24
+
25
+
26
+ def test_pair_prints_code_then_stores_the_approvers_session(home, monkeypatch, capsys):
27
+ answers = iter([{"status": "pending"},
28
+ {"status": "approved", "refresh_token": "r", "access_token": "a",
29
+ "email": "approver@example.com"}])
30
+ monkeypatch.setattr(cli_mod, "pair_start", lambda url: START)
31
+ monkeypatch.setattr(cli_mod, "pair_poll", lambda url, dev: next(answers))
32
+ assert main(["--server", "http://gb.invalid", "login", "--pair"]) == 0
33
+ err = capsys.readouterr().err
34
+ assert "ABCD-EFGH" in err
35
+ assert config.session()["refresh_token"] == "r"
36
+ assert config.session()["user"] == "approver@example.com"
37
+
38
+
39
+ def test_pair_refused_code_stores_nothing(home, monkeypatch):
40
+ monkeypatch.setattr(cli_mod, "pair_start", lambda url: START)
41
+
42
+ def boom(url, dev):
43
+ raise Refused(400, "expired")
44
+ monkeypatch.setattr(cli_mod, "pair_poll", boom)
45
+ assert main(["--server", "http://gb.invalid", "login", "--pair"]) == 1
46
+ assert not config.session().get("refresh_token")
@@ -0,0 +1,81 @@
1
+ """Machine reports from `gban doctor` / `gban setup` (GRPH-1442)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from gban import config, doctor, machine
7
+ from gban.cli import main
8
+ from gban.client import NoSession, Unreachable
9
+
10
+
11
+ @pytest.fixture()
12
+ def home(tmp_path, monkeypatch):
13
+ monkeypatch.setenv(config.HOME_ENV, str(tmp_path))
14
+ monkeypatch.setenv("CLAUDE_CONFIG_PATH", str(tmp_path / "claude.json"))
15
+ monkeypatch.setenv("GROK_CONFIG_PATH", str(tmp_path / "grok" / "config.toml"))
16
+ monkeypatch.chdir(tmp_path)
17
+ for var in (config.URL_ENV, config.PROJECT_ENV, config.API_KEY_ENV, doctor.SUPERVISOR_KEY_ENV):
18
+ monkeypatch.delenv(var, raising=False)
19
+ config.save_settings(url="http://gb.invalid", project="core")
20
+ return tmp_path
21
+
22
+
23
+ class _Rec:
24
+ def __init__(self):
25
+ self.calls = []
26
+
27
+ def call(self, method, path, body=None):
28
+ self.calls.append((method, path, body))
29
+ return {}
30
+
31
+
32
+ def _doctor(monkeypatch, lines, code, extra=()):
33
+ monkeypatch.setattr("gban.cli.doctor_mod.run", lambda *a, **k: (list(lines), code))
34
+ monkeypatch.setattr("gban.cli.doctor_mod.resolve_supervisor_key", lambda *a, **k: "")
35
+ return main(["doctor", *extra])
36
+
37
+
38
+ def test_machine_id_is_stable_and_wiping_home_makes_a_new_one(home):
39
+ first = machine.machine_id()
40
+ assert first == machine.machine_id()
41
+ (home / machine.MACHINE_FILE).unlink()
42
+ assert machine.machine_id() != first
43
+
44
+
45
+ def test_doctor_posts_its_lines_with_the_machine_id(home, monkeypatch):
46
+ rec = _Rec()
47
+ monkeypatch.setattr("gban.machine.authenticated", lambda url, act="": rec)
48
+ code = _doctor(monkeypatch, [doctor._line("ledger", "PASS", "server", "ok"),
49
+ doctor._line("local", "FAIL", "gbfleet", "bad")], 1)
50
+ assert code == 1
51
+ (method, path, body), = rec.calls
52
+ assert (method, path) == ("POST", "/api/machines/report")
53
+ assert body["machine_id"] == machine.machine_id() and body["project"] == "core"
54
+ assert [l["status"] for l in body["lines"]] == ["PASS", "FAIL"]
55
+
56
+
57
+ def test_no_report_sends_nothing(home, monkeypatch):
58
+ rec = _Rec()
59
+ monkeypatch.setattr("gban.machine.authenticated", lambda url, act="": rec)
60
+ assert _doctor(monkeypatch, [doctor._line("ledger", "PASS", "server")], 0, ["--no-report"]) == 0
61
+ assert rec.calls == []
62
+
63
+
64
+ @pytest.mark.parametrize("error", [Unreachable("could not reach http://gb.invalid: down"),
65
+ NoSession("no stored session")])
66
+ def test_an_undeliverable_report_prints_unknown_and_keeps_the_exit_code(home, monkeypatch, capsys, error):
67
+ """Sabotage: block the server. The command still exits on its own checks."""
68
+ def boom(url, act=""):
69
+ raise error
70
+ monkeypatch.setattr("gban.machine.authenticated", boom)
71
+ code = _doctor(monkeypatch, [doctor._line("ledger", "PASS", "server")], 0)
72
+ out = capsys.readouterr().out
73
+ assert code == 0
74
+ assert "UNKNOWN" in out and "could not report to http://gb.invalid" in out
75
+ code = _doctor(monkeypatch, [doctor._line("ledger", "FAIL", "session")], 1)
76
+ assert code == 1 # the report's own failure never turns a FAIL into success
77
+
78
+
79
+ def test_undelivered_line_is_unknown_never_pass(home):
80
+ line = machine._unreported("http://s", "why")
81
+ assert line["status"] == "UNKNOWN" and line["detail"] == "could not report to http://s: why"
@@ -1210,6 +1210,69 @@ def test_setup_refuses_to_write_a_key_that_would_not_be_ignored(tmp_path, wired,
1210
1210
  assert any("would not be gitignored" in l["detail"] for l in lines)
1211
1211
 
1212
1212
 
1213
+ # ---- resolving by git remote first (GRPH-1365) ---------------------------------------------------
1214
+
1215
+ import subprocess as _sp
1216
+
1217
+
1218
+ def _checkout(path, remote):
1219
+ path.mkdir()
1220
+ _sp.run(["git", "-C", str(path), "init", "-q"], check=True)
1221
+ _sp.run(["git", "-C", str(path), "remote", "add", "origin", remote], check=True)
1222
+ return path
1223
+
1224
+
1225
+ @pytest.mark.parametrize("raw,want", [
1226
+ ("https://github.com/Acme/App.git", "github.com/acme/app"),
1227
+ ("git@github.com:acme/app", "github.com/acme/app"),
1228
+ ("ssh://git@github.com:22/acme/app/", "github.com/acme/app"),
1229
+ ("github.com/acme/app", "github.com/acme/app"),
1230
+ ("git@github.com:1password/cli.git", "github.com/1password/cli"),
1231
+ ("https://github.com/1password/cli", "github.com/1password/cli"),
1232
+ ("ssh://git@github.com/37signals/x", "github.com/37signals/x"),
1233
+ ("git@github.com:37signals/x", "github.com/37signals/x"),
1234
+ ("https://user:tok@gitlab.com/a/b/c.git", "gitlab.com/a/b/c"),
1235
+ ("app", ""), ("", ""),
1236
+ ])
1237
+ def test_normalize_remote_table_matches_the_backend(raw, want):
1238
+ assert setup_mod.normalize_remote(raw) == want
1239
+
1240
+
1241
+ def test_a_matching_remote_picks_the_project_even_when_names_differ(tmp_path):
1242
+ here = _checkout(tmp_path / "checkout-7", "git@github.com:Acme/App.git")
1243
+ rows = [{"id": "core", "name": "Core", "repo_url": "github.com/acme/app"},
1244
+ {"id": "checkout-7", "name": "Decoy", "repo_url": "github.com/other/thing"}]
1245
+
1246
+ pid, how = setup_mod.resolve_project(Listing(rows), here)
1247
+
1248
+ assert pid == "core" and "git remote" in how
1249
+
1250
+
1251
+ def test_no_matching_remote_falls_back_to_the_name(tmp_path):
1252
+ here = _checkout(tmp_path / "super-arc", "git@github.com:acme/unrelated.git")
1253
+ rows = [{"id": "super-arc", "name": "Super Arc", "repo_url": "github.com/acme/app"}]
1254
+
1255
+ pid, how = setup_mod.resolve_project(Listing(rows), here)
1256
+
1257
+ assert pid == "super-arc" and "directory name" in how
1258
+
1259
+
1260
+ def test_a_remote_matching_two_projects_is_refused(tmp_path):
1261
+ here = _checkout(tmp_path / "x", "https://github.com/acme/app")
1262
+ rows = [{"id": "a", "name": "A", "repo_url": "github.com/acme/app"},
1263
+ {"id": "b", "name": "B", "repo_url": "github.com/acme/app"}]
1264
+
1265
+ with pytest.raises(setup_mod.Unresolved):
1266
+ setup_mod.resolve_project(Listing(rows), here)
1267
+
1268
+
1269
+ def test_a_project_without_repo_url_never_matches_an_empty_remote(tmp_path):
1270
+ here = tmp_path / "plain"
1271
+ here.mkdir() # not a checkout: no remotes
1272
+ with pytest.raises(setup_mod.Unresolved):
1273
+ setup_mod.resolve_project(Listing([{"id": "a", "name": "A"}]), here)
1274
+
1275
+
1213
1276
  def test_web_agent_list_is_generated_from_setup_agents():
1214
1277
  """The quick-start picker's gban-route list (GRPH-1362 / G9) is exported, not copied."""
1215
1278
  import importlib.util
@@ -1245,6 +1308,50 @@ def test_quickstart_commands_parse_with_the_cli_parser():
1245
1308
  assert ns.project == "core"
1246
1309
 
1247
1310
 
1311
+ def _uv_tool_install_packages(command: str) -> list[list[str]]:
1312
+ """The positional arguments of each `uv tool install` in a `&&`-chained line.
1313
+
1314
+ `uv tool install [OPTIONS] <PACKAGE>` takes exactly one package; a second positional
1315
+ exits with "unexpected argument". Options (`--with x`, `-p 3.12`) are skipped with
1316
+ their value so only real positionals are counted."""
1317
+ import shlex
1318
+
1319
+ takes_value = {"--with", "-w", "--python", "-p", "--from", "--index", "--index-url"}
1320
+ out = []
1321
+ for part in command.split("&&"):
1322
+ argv = shlex.split(part)
1323
+ assert argv[:3] == ["uv", "tool", "install"], part
1324
+ pos, i = [], 3
1325
+ while i < len(argv):
1326
+ a = argv[i]
1327
+ if a.startswith("-"):
1328
+ i += 2 if a in takes_value else 1
1329
+ continue
1330
+ pos.append(a)
1331
+ i += 1
1332
+ out.append(pos)
1333
+ return out
1334
+
1335
+
1336
+ def test_quickstart_install_line_installs_both_packages_one_per_uv_call():
1337
+ """Bounced once: `uv tool install graphban-cli graphban-fleet` exits 2 — uv takes one
1338
+ package per install. The line must install both tools, each in its own `uv tool install`."""
1339
+ from gban.setup import QUICKSTART_COMMANDS
1340
+
1341
+ installs = [c for c in QUICKSTART_COMMANDS if c.startswith("uv ")]
1342
+ assert len(installs) == 1, "the dialog shows exactly one install line"
1343
+ packages = _uv_tool_install_packages(installs[0])
1344
+ assert all(len(p) == 1 for p in packages), f"one package per uv tool install: {packages}"
1345
+ assert sorted(p[0] for p in packages) == ["graphban-cli", "graphban-fleet"]
1346
+
1347
+
1348
+ def test_the_bounced_install_line_is_rejected_by_the_check():
1349
+ """Sabotage for the test above: the bounced string must fail it, or it proves nothing."""
1350
+ packages = _uv_tool_install_packages("uv tool install graphban-cli graphban-fleet")
1351
+ assert packages == [["graphban-cli", "graphban-fleet"]]
1352
+ assert not all(len(p) == 1 for p in packages)
1353
+
1354
+
1248
1355
  def test_the_old_subcommand_flag_order_is_still_rejected():
1249
1356
  """Sabotage for the test above: the bounced strings must fail to parse, or it proves nothing."""
1250
1357
  import pytest
@@ -1254,3 +1361,22 @@ def test_the_old_subcommand_flag_order_is_still_rejected():
1254
1361
  for argv in (["login", "--server", "https://x"], ["setup", "--project", "core"]):
1255
1362
  with pytest.raises(SystemExit):
1256
1363
  _parser().parse_args(argv)
1364
+
1365
+
1366
+ def test_graphban_work_names_the_lease_the_graph_and_prove():
1367
+ """The work skill must warn that a silent long command drops the lease, send
1368
+ changed paths to describe_code, call a receipt a test line, and hand
1369
+ finishing to graphban-prove."""
1370
+ body = " ".join(setup_mod.skill_source("graphban-work").read_text(encoding="utf-8").split())
1371
+ assert "Heartbeat before any command that can run longer than the lease" in body
1372
+ assert "`describe_code` those paths" in body
1373
+ assert "A review-ready receipt is a test line" in body
1374
+ assert "Finishing the item is `graphban-prove`" in body
1375
+
1376
+
1377
+ def test_the_image_manifest_lists_the_shipped_skills():
1378
+ """backend/scripts/gban_skills.json is a checked-in copy (the image has no cli/)."""
1379
+ root = Path(__file__).resolve().parents[2]
1380
+ manifest = json.loads((root / "backend/scripts/gban_skills.json").read_text())
1381
+ shipped = {p.name for p in (root / "cli/src/gban/skills").iterdir() if p.is_dir()}
1382
+ assert set(manifest["skills"]) == shipped
@@ -124,7 +124,7 @@ wheels = [
124
124
 
125
125
  [[package]]
126
126
  name = "graphban-cli"
127
- version = "0.8.4"
127
+ version = "0.8.6"
128
128
  source = { editable = "." }
129
129
 
130
130
  [package.optional-dependencies]
File without changes
File without changes