graphban-cli 0.8.2__tar.gz → 0.8.4__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 (34) hide show
  1. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/.gitignore +4 -0
  2. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/PKG-INFO +3 -2
  3. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/README.md +2 -1
  4. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/pyproject.toml +1 -1
  5. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/cli.py +13 -3
  6. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/doctor.py +90 -10
  7. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/setup.py +23 -3
  8. graphban_cli-0.8.4/src/gban/skills/graphban-answer/SKILL.md +103 -0
  9. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-delegation/SKILL.md +2 -1
  10. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-review/SKILL.md +2 -0
  11. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-wave/SKILL.md +2 -0
  12. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-work/SKILL.md +6 -0
  13. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/watch-wave/SKILL.md +4 -3
  14. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/swamp.py +6 -4
  15. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_doctor.py +72 -0
  16. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_setup.py +84 -1
  17. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_swamp.py +15 -1
  18. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/uv.lock +1 -1
  19. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/LICENSE +0 -0
  20. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/__init__.py +0 -0
  21. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/client.py +0 -0
  22. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/config.py +0 -0
  23. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/data/stack_sdks.toml +0 -0
  24. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/gitignore.py +0 -0
  25. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-grill/SKILL.md +0 -0
  26. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-orient/SKILL.md +0 -0
  27. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-prd/SKILL.md +0 -0
  28. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-scan/SKILL.md +0 -0
  29. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/stackmap.py +0 -0
  30. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_acts.py +0 -0
  31. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_login_projects.py +0 -0
  32. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_packaging.py +0 -0
  33. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_session.py +0 -0
  34. {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_stackmap.py +0 -0
@@ -26,6 +26,10 @@ backend/.pytest_cache/
26
26
  fleet/.venv/
27
27
  fleet/.pytest_cache/
28
28
  *.egg-info/
29
+ # Written at package build time by `refusal_ratchet.py publish` (the packer, the wheel job),
30
+ # never committed: a CI push to protected main is refused and turns main red (GRPH-1185).
31
+ backend/app/data/refusal_coverage.json
32
+ backend/app/data/gban_skills.json
29
33
 
30
34
  # Node
31
35
  web/node_modules/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.8.2
3
+ Version: 0.8.4
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
@@ -180,7 +180,8 @@ gban swamp setup
180
180
  Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
181
181
  `repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
182
182
  piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
183
- Every step is skipped when already done.
183
+ Every step is skipped when already done. When the vault secret is already set it is left alone;
184
+ `gban swamp setup --replace` mints a new gate credential and overwrites that vault entry.
184
185
 
185
186
  Two things it will not do. It does not install Swamp, because that install pipes a remote
186
187
  script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
@@ -165,7 +165,8 @@ gban swamp setup
165
165
  Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
166
166
  `repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
167
167
  piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
168
- Every step is skipped when already done.
168
+ Every step is skipped when already done. When the vault secret is already set it is left alone;
169
+ `gban swamp setup --replace` mints a new gate credential and overwrites that vault entry.
169
170
 
170
171
  Two things it will not do. It does not install Swamp, because that install pipes a remote
171
172
  script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.8.2"
3
+ version = "0.8.4"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -88,6 +88,9 @@ def _parser() -> argparse.ArgumentParser:
88
88
  swamp_setup = swamp_do.add_parser("setup", help="init, source, vault, and the gate key")
89
89
  swamp_setup.add_argument("--adapter", default=swamp_mod.DEFAULT_ADAPTER,
90
90
  help=f"the graphban-swamp checkout (default: {swamp_mod.DEFAULT_ADAPTER})")
91
+ swamp_setup.add_argument("--replace", action="store_true",
92
+ help="when the vault secret is already set, mint a new gate "
93
+ "credential and overwrite it")
91
94
  swamp_setup.add_argument("--no-init", action="store_true",
92
95
  help="refuse rather than run `swamp repo init` here")
93
96
 
@@ -322,7 +325,7 @@ def _cwd_project_mismatch(client, configured: str) -> str:
322
325
  def cmd_doctor(args) -> int:
323
326
  url = config.resolve(args.server, config.URL_ENV, "url")
324
327
  project = config.resolve(args.project, config.PROJECT_ENV, "project")
325
- api_key = doctor_mod.resolve_supervisor_key(project)
328
+ api_key = doctor_mod.resolve_supervisor_key(project, url=url)
326
329
  lines, code = doctor_mod.run(url, project, api_key)
327
330
  _out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
328
331
  return code
@@ -415,7 +418,8 @@ def cmd_swamp(args) -> int:
415
418
  print(f"{PROG}: {exc}", file=sys.stderr)
416
419
  return EXIT_REFUSED
417
420
  lines, code = swamp_mod.setup(client, url, project, repo,
418
- Path(args.adapter).expanduser(), init=not args.no_init)
421
+ Path(args.adapter).expanduser(), init=not args.no_init,
422
+ replace=args.replace)
419
423
  human = [f"{PROG}: {project} — {how}" if how != "named" else f"{PROG}: {project}",
420
424
  doctor_mod.render(lines)]
421
425
  if code == 0:
@@ -513,7 +517,13 @@ def cmd_fleet(args) -> int:
513
517
  f" …or `brew install asc-me/tap/gban` installs this client only — the "
514
518
  f"supervisor is a separate package.", file=sys.stderr)
515
519
  return EXIT_NO_SUPERVISOR
516
- argv = [binary, *[a for a in args.rest if a != "--"]]
520
+ # Only a LEADING `--` is ours to drop (`gban fleet -- ps`). Any later one belongs to
521
+ # gbfleet: `service install --name X -- <until args>` splits on it, and stripping it
522
+ # handed the until args to `service install` itself.
523
+ rest = list(args.rest)
524
+ if rest[:1] == ["--"]:
525
+ rest = rest[1:]
526
+ argv = [binary, *rest]
517
527
  url = config.resolve(args.server, config.URL_ENV, "url")
518
528
  if url and "--server" not in argv:
519
529
  argv += ["--server", url]
@@ -249,7 +249,62 @@ def find_supervisor() -> str:
249
249
  SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
250
250
 
251
251
 
252
- def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
252
+ def supervisor_key_candidates(project: str, repo: Path | None = None) -> list[tuple[str, str]]:
253
+ """Every key we could hand the supervisor, as (source name, key), in preference order.
254
+
255
+ The names are for messages; the values never are (GRPH-1346).
256
+ """
257
+ found: list[tuple[str, str]] = []
258
+ env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
259
+ if env_key:
260
+ found.append((f"${config.API_KEY_ENV}", env_key))
261
+ stored = config.stored_supervisor_key(project)
262
+ if stored:
263
+ found.append(("the key `gban setup` stored for this project", stored))
264
+ for key in _harness_supervisor_keys(repo):
265
+ found.append(("a key `gban setup` wrote into a harness config", key))
266
+ unique: list[tuple[str, str]] = []
267
+ for name, key in found:
268
+ if key not in [k for _, k in unique]:
269
+ unique.append((name, key))
270
+ return unique
271
+
272
+
273
+ # GRPH-1346: the same body as `gbfleet.client.OBSERVATION_PROBE`. It must validate: the
274
+ # route's `item_id` is required, and a body without it is 422 before the credential check,
275
+ # so every key would read as able. A real kind and an item no key resolves: 401/403 is the
276
+ # refusal, 404 "no such item" is the pass, and nothing is written either way.
277
+ PROBE_BODY = {"item_id": "__gbfleet_probe_no_such_item__", "kind": "cut_from"}
278
+
279
+
280
+ def can_supervise(url: str, key: str, timeout: float = 5.0) -> bool | None:
281
+ """Whether the observations route accepts this key: False on 401/403, True on 404 (the
282
+ probe item does not exist, so the credential gate passed) or 2xx, None when the server
283
+ could not be asked or answered anything else — a check that did not run is not a pass."""
284
+ import json
285
+ import urllib.error
286
+ import urllib.request
287
+
288
+ if not url.startswith(("http://", "https://")):
289
+ # urlopen would read `file:` and friends too, and send the credential with it.
290
+ return None
291
+ req = urllib.request.Request(
292
+ url.rstrip("/") + "/api/fleet/observations", data=json.dumps(PROBE_BODY).encode(),
293
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
294
+ method="POST")
295
+ try:
296
+ # bandit B310: the scheme is checked to be http(s) above.
297
+ urllib.request.urlopen(req, timeout=timeout).close() # nosec B310
298
+ except urllib.error.HTTPError as exc:
299
+ if exc.code in (401, 403):
300
+ return False
301
+ return True if exc.code == 404 else None
302
+ except Exception: # noqa: BLE001 — could not ask is not a verdict
303
+ return None
304
+ return True
305
+
306
+
307
+ def resolve_supervisor_key(project: str, repo: Path | None = None, url: str = "") -> str:
253
308
  """The agent key we hand `child_environment` as `api_key`.
254
309
 
255
310
  Order (GRPH-782 remaining hole): `$GRAPHBAN_API_KEY`, then the project-scoped key
@@ -257,19 +312,44 @@ def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
257
312
  harness dest for this repository. `$GBFLEET_API_KEY` the caller set is preserved
258
313
  inside `child_environment`, so it is not resolved here.
259
314
 
315
+ With `url`, a candidate the observations route refuses is passed over for the next one
316
+ that it accepts (GRPH-1346): a key that can build but not supervise cost a whole wave its
317
+ base, lease and PR records. When none can, the first is returned and `until` refuses it
318
+ by name; `supervisor_key_verdict` says why. Without `url` the order alone decides.
319
+
260
320
  Never the login session. A refresh token is a different kind of credential.
261
321
  """
262
- env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
263
- if env_key:
264
- return env_key
265
- stored = config.stored_supervisor_key(project)
266
- if stored:
267
- return stored
268
- return _harness_supervisor_key(repo)
322
+ candidates = supervisor_key_candidates(project, repo)
323
+ if not candidates:
324
+ return ""
325
+ if url:
326
+ for _, key in candidates:
327
+ if can_supervise(url, key) is not False:
328
+ return key
329
+ return candidates[0][1]
330
+
331
+
332
+ def supervisor_key_verdict(project: str, url: str, repo: Path | None = None) -> str:
333
+ """Why no candidate key can supervise, naming sources and never values; "" when one can
334
+ or when the server could not be asked."""
335
+ candidates = supervisor_key_candidates(project, repo)
336
+ if not candidates or not url:
337
+ return ""
338
+ results = [(name, can_supervise(url, key)) for name, key in candidates]
339
+ if any(r is not False for _, r in results):
340
+ return ""
341
+ return ("no key can supervise (the server refused each for observations; recording needs "
342
+ "the planner role and a fleet wave or project scope): "
343
+ + "; ".join(name for name, _ in results))
269
344
 
270
345
 
271
346
  def _harness_supervisor_key(repo: Path | None) -> str:
272
- """The key `gban setup` already wrote into a parent-harness dest.
347
+ keys = _harness_supervisor_keys(repo)
348
+ return keys[0] if keys else ""
349
+
350
+
351
+ def _harness_supervisor_keys(repo: Path | None) -> list[str]:
352
+ """The keys `gban setup` already wrote into a parent-harness dest.
273
353
 
274
354
  Lazy-imports setup: that module imports this one, and a top-level cycle would
275
355
  make `gban doctor` fail to import on a machine that has never run setup.
@@ -285,7 +365,7 @@ def _harness_supervisor_key(repo: Path | None) -> str:
285
365
  key = (setup_mod.dest_key(dest, repo) or "").strip()
286
366
  if key.startswith(config.AGENT_KEY_PREFIX) and key not in seen:
287
367
  seen.append(key)
288
- return seen[0] if seen else ""
368
+ return seen
289
369
 
290
370
 
291
371
  def child_environment(api_key: str) -> dict:
@@ -49,6 +49,24 @@ TIERS = ["fleet", "prd"]
49
49
  LEDGER_SERVER = "graphban"
50
50
  FLEET_SERVER = "gbfleet"
51
51
 
52
+ #: The agents `gban setup` writes a harness config for (see `destinations`). The web
53
+ #: quick-start's agent picker offers the gban route to exactly these and switches the rest to
54
+ #: paste-a-key (GRPH-P67 G9). `scripts/gen_gban_agents.py` exports this to the web bundle;
55
+ #: `cli/tests/test_setup.py` fails when that file drifts, so adding a harness here is the
56
+ #: one edit that changes what the dialog offers.
57
+ SETUP_AGENTS = ("claude", "grok")
58
+
59
+ #: The commands the quick-start dialog's gban route shows, in order, with `{server}` and
60
+ #: `{project}` filled in by the web app. Exported beside SETUP_AGENTS so the dialog never
61
+ #: types its own copy; `cli/tests/test_setup.py` parses each with `gban.cli._parser()`, so a
62
+ #: flag that moves (e.g. `--server` is a top-level option, not a `login` one) fails a test
63
+ #: rather than a person's terminal.
64
+ QUICKSTART_COMMANDS = (
65
+ "uv tool install graphban-cli",
66
+ "gban --server {server} login",
67
+ "gban --project {project} setup",
68
+ )
69
+
52
70
 
53
71
  # ---- where the config goes ------------------------------------------------------------------
54
72
 
@@ -375,7 +393,8 @@ def mint(client: Client, project: str, label: str = "") -> dict:
375
393
  "project_id": project,
376
394
  # NULL, deliberately. See the module docstring: only seats expire.
377
395
  "expires_in_days": None,
378
- "tool_tiers": TIERS})
396
+ "tool_tiers": TIERS,
397
+ "origin": "gban_setup"})
379
398
 
380
399
 
381
400
  def verify(url: str, key: str, project: str) -> list[dict]:
@@ -474,8 +493,9 @@ def run(client: Client, url: str, project: str, repo: Path, *, scope: str = "use
474
493
  # A key that cannot do the job is replaced, never patched: `tool_tiers` are fixed at
475
494
  # mint and there is no route that changes them.
476
495
  lines.append(_line("config", UNKNOWN, "credential",
477
- "the configured key cannot delegate, so a new one is being minted; "
478
- "the old one is still live — remove it in Settings → API keys"))
496
+ "the configured credential no longer works (revoked, or it cannot "
497
+ "delegate), so a new one is being minted; if the old one still "
498
+ "works, remove it in Settings → API keys"))
479
499
  if not key:
480
500
  try:
481
501
  minted = mint(client, project)
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: graphban-answer
3
+ description: >
4
+ Answer a question from the session, the PRD, the product why, published
5
+ memory, and a related decision that already settled the same act. Ask the
6
+ user only when those sources do not decide. Use when a wait:decision is
7
+ open, before asking the user a mid-build question, during a wave or spawn,
8
+ or when the user runs /graphban-answer. Grill questions stay on
9
+ /graphban-grill.
10
+ user-invocable: true
11
+ ---
12
+
13
+ # Answer
14
+
15
+ You are the session that launched the work. A child does not run this. `gbfleet until` does not run this. `watch-wave` reports and leaves every wait blocked.
16
+
17
+ Grill questions are `graphban-grill`. Do not `answer_grill`. Do not `update_prd`. Lane, tier, and harness stay where they were chosen.
18
+
19
+ Pass `project_id` on `search_items` and `search_memory`. A key that can see several projects writes to the wrong one when you omit it.
20
+
21
+ ## When
22
+
23
+ In this session, before you ask the user.
24
+
25
+ A drain or a spawn: in the same turn `gbfleet service install` reports the drain running, or the first `spawn` returns an agent id, attach a scheduler beside `watch-wave`. `scheduler_list` first. One loop per project.
26
+
27
+ `scheduler_create` with `interval="5m"`, `fire_immediately=true`, `durable=false`. The stored prompt stands alone.
28
+
29
+ ```
30
+ You answer Graphban wait:decision items for project PROJECT. This prompt is self-contained. Follow the graphban-answer skill if you can read it. If you cannot, do only what is written here.
31
+
32
+ PRD scope, when the drain has one: PRD. Skip a wait that is not on that PRD.
33
+
34
+ search_items status=blocked tags=["wait:decision"] project_id=PROJECT.
35
+ get_item_details on each id. A description with no question: print the id and leave it blocked.
36
+
37
+ Sources, in order. A later source does not override an earlier one. Quote the sentence or leave the wait blocked.
38
+ 1. get_prd. Framing only: Problem, Goals, Non-goals, an intent heading, Decisions from grilling. docs/product-overview.md and docs/design-philosophy.md bound the PRD. They do not fill a hole the PRD left open.
39
+ 2. search_memory for the question, project_id=PROJECT. Leave include_candidates off. The shard text must be the decision. A score is not a vote.
40
+ 3. A related decision that already settled the same act, in one step. search_items status=done tags=["wait:decision"] project_id=PROJECT, keep this PRD, and read the evidence note. related_work on the original, then get_item_details on a done wait:decision it names. Quote that decision and name the one step. A longer chain is ask. A non-goal that refuses to choose is not a decision.
41
+
42
+ Leave wait:secret, wait:access, wait:deploy, and wait:merge blocked. Name them.
43
+
44
+ A quote: evidence on the wait, kind note, the sentence and where it came from. update_item status=done on the wait only.
45
+ Do not update_prd. Do not answer_grill. Do not move the original item. Do not publish_memory.
46
+
47
+ No quote: print the question, your recommendation, and one line on why the sources do not decide. Leave the wait blocked.
48
+
49
+ Do not spawn, merge, sign_off, bounce, or claim.
50
+ ```
51
+
52
+ Substitute PROJECT and PRD. No placeholders left in the stored prompt.
53
+
54
+ ## Sources
55
+
56
+ Read these before you classify. A source decides only when you can quote the sentence. A later source does not override an earlier one.
57
+
58
+ 1. A sentence the user already said in this session. A scheduler fire has no session. Absence of that sentence decides nothing.
59
+ 2. `get_prd` for the item's PRD. Framing only: Problem, Goals, Non-goals, an intent heading, Decisions from grilling. `docs/product-overview.md` and `docs/design-philosophy.md` bound the PRD. They do not fill a hole the PRD left open.
60
+ 3. `search_memory`. Leave `include_candidates` off. A shard counts when its text is the decision. A lesson about a mechanism does not. Do not call the decider. A score is not a vote.
61
+ 4. A related decision that already settled the same act. Quote it, and name the one step that makes this question that act. Look here:
62
+ - `related_work` on the original, then `get_item_details` on a hit that is a done `wait:decision`. The evidence note is the decision.
63
+ - `search_items(status=done, tags=["wait:decision"])` for this project. Keep a row whose `prd_id` matches. The evidence note is the decision.
64
+
65
+ One step is the same actor doing the same thing to the same kind of thing. "The wave does not retry a failed child" decides "should the wave start a failed reviewer again", because a reviewer is a child and starting it again is a retry. It does not decide "should a person be paged" or "should the retry be billed."
66
+
67
+ A longer chain than one step is ask. Two related decisions that disagree are ask. A non-goal that says the work does not choose is still ask. The product why still does not fill a hole the PRD left open.
68
+
69
+ Code says what the repo does. It is not a source.
70
+
71
+ Silence is ask. A conflict is ask. Two reasonable readings are ask. A new contract or a new error behavior that no recorded decision states is ask.
72
+
73
+ ## Leave blocked
74
+
75
+ `wait:secret`, `wait:access`, `wait:deploy`, and `wait:merge`. Gitops, sign-off, and spend. Name the item. Leave it blocked.
76
+
77
+ ## The wait
78
+
79
+ `search_items(status=blocked, tags=["wait:decision"])`.
80
+
81
+ `get_item_details`. The description is the question and what each answer would make the child do. A title and no question is ask. Say the wait has no question.
82
+
83
+ A `--prd` drain answers a wait whose `prd_id` matches, or whose dependency link has `b` this wait and `a` an item on that PRD. Leave every other wait.
84
+
85
+ The original is `a` on that dependency link. You are not its builder. Leave it blocked. Marking the wait `done` returns it to `next` when no other unmet dependency remains.
86
+
87
+ ## Quote
88
+
89
+ The answer is the quoted sentence and where it came from. When the quote is a related decision, the note also names the one step. `update_item` on the wait: evidence `kind` `note` with that sentence, then `status=done`.
90
+
91
+ A decision that should outlive the item: `add_memory`, scope `global`. Read the returned `status`. Leave a `candidate`. Do not `publish_memory`.
92
+
93
+ ## Ask
94
+
95
+ One question. The question, your recommendation, and one line on why the sources do not decide it.
96
+
97
+ Record nothing until the user answers. Their words are the evidence, then the wait is `done`. The recommendation alone is not the answer.
98
+
99
+ A scheduler fire that must ask prints that question and leaves the wait blocked. The parent session puts it to the user.
100
+
101
+ ## Stop
102
+
103
+ `scheduler_delete` when the user says stop, when the drain is gone, or when no `wait:decision` remains. A secret, access, deploy, or merge wait stays. Name it.
@@ -175,7 +175,8 @@ tool results instead of by a child's transcript.
175
175
  Do not wait for the user to ask how the wave is doing. After the first successful spawn,
176
176
  attach a periodic watcher with the harness's scheduler/loop so status comes back on its
177
177
  own. In Grok that is the `watch-wave` skill (`scheduler_create`). Going idle after spawn
178
- is why they have to nudge.
178
+ is why they have to nudge. Attach `graphban-answer` in that same turn. A decision the
179
+ child cannot quote comes back as a `wait:decision`.
179
180
 
180
181
  Three independent timers: the delegation lease (600s) decides open versus expired, the item
181
182
  lease starts fresh at the claim, and the seat expires after 30 minutes. `expired, nothing
@@ -18,4 +18,6 @@ Review is a second person. `claim_review` refuses the builder.
18
18
  5. `bounce(id, reason)` sends it to `next`. The author keeps the worktree for one lease, then the fleet can take it. The person who delegated the item may bounce it. They may not sign it off.
19
19
  6. When the item cites a PRD, call `prd_acceptance` with one `view`. Those views describe what happened. They do not report complete. `get_prd` for the whole body is an edit, not a review. Authoring is `graphban-prd`.
20
20
 
21
+ Test scope follows the project's mode, which the delegation brief states. `agent_targeted`: read CI's `suite_green` for the head, rerun the targeted tests and do your own sabotage, without rerunning full suites. `agent_and_pr`: rerun the full suites per `AGENTS.md`'s operating loop. `agent_full`: rerun the full suites; receipts are the proof. In every mode, a full local run is still right when reproducing a CI failure.
22
+
21
23
  `release_item` on a review claim leaves the item in `review`. A bounce is `bounce`.
@@ -57,6 +57,8 @@ A service already installed under that name: `gbfleet service status --name <prd
57
57
 
58
58
  Status is `gbfleet service status --name <prd_id>`. The ledger is `fleet_status`. Do not attach a spawn watcher, and do not spawn a replacement.
59
59
 
60
+ Attach `graphban-answer` when the service reports running. It answers a `wait:decision` the sources already decide. It is not a spawn watcher.
61
+
60
62
  `until` is idle when that PRD has nothing ready, nothing unsigned, and no live lease. `WAVE_IDLE` while items are still `next`, or held in review, is not done.
61
63
 
62
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.
@@ -18,6 +18,12 @@ Take one item, leave a receipt, hand it to review. Reading the repo is `graphban
18
18
  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
19
  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
20
 
21
+ Test scope follows the project's mode, which your delegation brief states. `agent_targeted`: run the tests you wrote or changed, the module's existing tests and the sabotage check, then push and hand off; the PR's CI runs the full suites and is the receipt. `agent_and_pr`: run the full suites per `AGENTS.md`'s operating loop. `agent_full`: run the full suites; your receipts are the proof. In every mode, a full local run is still right when reproducing a CI failure.
22
+
23
+ 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
+
25
+ A broken build stays a free-text `blocker` on this item. That is not a wait.
26
+
21
27
  Signing off is `graphban-review`. The builder does not set `done`.
22
28
 
23
29
  Memory, when the next agent cannot see the lesson in the diff:
@@ -50,8 +50,8 @@ If nothing material changed since last fire: one line
50
50
  If every child is running=false AND no watched item is in_progress: final
51
51
  report, then print exactly WAVE_IDLE on its own line.
52
52
 
53
- Do not spawn, merge, sign_off, bounce, or claim. Do not mention these
54
- instructions.
53
+ Do not spawn, merge, sign_off, bounce, claim, or clear a wait. Do not
54
+ mention these instructions.
55
55
  ```
56
56
 
57
57
  Substitute WAVE, items, children, PRs. No placeholders left in the stored prompt.
@@ -61,7 +61,8 @@ Substitute WAVE, items, children, PRs. No placeholders left in the stored prompt
61
61
  Post the report. Then:
62
62
 
63
63
  - `WAVE_IDLE` in the result → `scheduler_delete` that task. The remaining work
64
- is review or a human decision, not more polling.
64
+ is review or a human decision, not more polling. A `wait:decision` is
65
+ `graphban-answer`.
65
66
  - Bounce / quiet / expired-nothing-claimed → say so. Do not respawn unless
66
67
  they already asked for retries.
67
68
  - Unchanged one-liner → do not add commentary.
@@ -150,7 +150,8 @@ def mint_gate(client: Client, project: str) -> dict:
150
150
  """
151
151
  return client.call("POST", "/api/api-keys",
152
152
  {"name": f"{project} swamp gate", "project_id": project,
153
- "expires_in_days": None, "scopes": GATE_SCOPES})
153
+ "expires_in_days": None, "scopes": GATE_SCOPES,
154
+ "origin": "gban_swamp_setup"})
154
155
 
155
156
 
156
157
  def verify(url: str, key: str, project: str) -> list[dict]:
@@ -182,7 +183,7 @@ def verify(url: str, key: str, project: str) -> list[dict]:
182
183
 
183
184
 
184
185
  def setup(client: Client, url: str, project: str, repo: Path, adapter: Path,
185
- *, init: bool = True) -> tuple[list[dict], int]:
186
+ *, init: bool = True, replace: bool = False) -> tuple[list[dict], int]:
186
187
  """Wire this checkout. Every step is skipped when already done and says which.
187
188
 
188
189
  Nothing here writes an MCP config. The gate key goes into the vault and nowhere else.
@@ -241,12 +242,13 @@ def setup(client: Client, url: str, project: str, repo: Path, adapter: Path,
241
242
  f"vault create failed: {_why(done)}")], 1
242
243
  lines.append(_line("local", PASS, "vault", f"created {VAULT!r} ({VAULT_TYPE})"))
243
244
 
244
- if secret_present(repo):
245
+ if secret_present(repo) and not replace:
245
246
  # Left alone rather than replaced. Overwriting would strand a credential that is
246
247
  # still live on the server, and the person may have put a deliberate one there.
247
248
  lines.append(_line("local", PASS, "secret",
248
249
  f"{VAULT}/{SECRET_KEY} already set — left alone. Delete it first "
249
- "to mint a new one"))
250
+ "to mint a new one, or rerun with --replace to mint one and "
251
+ "overwrite it"))
250
252
  return lines, _worst(lines)
251
253
 
252
254
  try:
@@ -181,6 +181,36 @@ def test_an_explicit_server_is_not_overridden(home, monkeypatch):
181
181
  assert "http://gb.invalid" not in seen["argv"]
182
182
 
183
183
 
184
+ def test_a_separator_inside_the_gbfleet_args_reaches_gbfleet(home, monkeypatch):
185
+ """`service install --name X -- <until args>` splits on that `--`. Stripping it made
186
+ gbfleet read `--repo` as its own flag and refuse. Sabotage: drop every `--` again and
187
+ this fails."""
188
+ seen = {}
189
+
190
+ class Done:
191
+ returncode = 0
192
+
193
+ monkeypatch.setattr("gban.cli.shutil.which", lambda name: "/usr/bin/gbfleet")
194
+ monkeypatch.setattr("gban.cli.subprocess.run",
195
+ lambda argv, **kw: (seen.update(argv=argv), Done())[1])
196
+ main(["fleet", "service", "install", "--name", "P1", "--", "--repo", "/r", "--prd", "P1"])
197
+ assert seen["argv"][:8] == ["/usr/bin/gbfleet", "service", "install", "--name", "P1",
198
+ "--", "--repo", "/r"]
199
+
200
+
201
+ def test_a_leading_separator_is_still_dropped(home, monkeypatch):
202
+ seen = {}
203
+
204
+ class Done:
205
+ returncode = 0
206
+
207
+ monkeypatch.setattr("gban.cli.shutil.which", lambda name: "/usr/bin/gbfleet")
208
+ monkeypatch.setattr("gban.cli.subprocess.run",
209
+ lambda argv, **kw: (seen.update(argv=argv), Done())[1])
210
+ main(["fleet", "--", "ps"])
211
+ assert seen["argv"][:2] == ["/usr/bin/gbfleet", "ps"]
212
+
213
+
184
214
  def test_a_missing_supervisor_says_how_to_install_it(home, monkeypatch, capsys):
185
215
  """7. Exit 4, its own code, so it cannot be confused with something gbfleet said.
186
216
 
@@ -743,3 +773,45 @@ def test_doctor_fails_a_tracked_credential_even_when_gitignore_names_it(
743
773
  detail = " ".join(l["detail"] for l in lines if l["name"] == "gitignore")
744
774
  assert "git tracks" in detail and ".mcp.json" in detail
745
775
  assert "git rm --cached" in detail
776
+
777
+
778
+ def test_resolve_prefers_a_key_that_can_supervise_and_says_why_none_can(home, monkeypatch):
779
+ """GRPH-1346. Sabotage: drop the `can_supervise` loop and the first key wins."""
780
+ monkeypatch.setenv(config.API_KEY_ENV, "gb_sk_NOPLANNER")
781
+ config.save_supervisor_key("core", "gb_sk_PLANNER")
782
+ verdicts = {"gb_sk_NOPLANNER": False, "gb_sk_PLANNER": True}
783
+ monkeypatch.setattr(doctor, "can_supervise", lambda url, key, timeout=5.0: verdicts[key])
784
+ assert doctor.resolve_supervisor_key("core", url="http://x") == "gb_sk_PLANNER"
785
+ assert doctor.resolve_supervisor_key("core") == "gb_sk_NOPLANNER"
786
+ assert doctor.supervisor_key_verdict("core", "http://x") == ""
787
+
788
+ verdicts["gb_sk_PLANNER"] = False
789
+ assert doctor.resolve_supervisor_key("core", url="http://x") == "gb_sk_NOPLANNER"
790
+ why = doctor.supervisor_key_verdict("core", "http://x")
791
+ assert "no key can supervise" in why and "GRAPHBAN_API_KEY" in why
792
+ assert "gb_sk_" not in why
793
+
794
+
795
+ def test_can_supervise_posts_a_validating_probe_and_reads_only_403_and_404(monkeypatch):
796
+ """GRPH-1346 bounce. `{"kind": "probe"}` is 422 at the real route for every key, and
797
+ "not 403" read as "can supervise". The body now names an item no key resolves, so 404 is
798
+ the pass; 422 (the check did not run) is None, not True. The real route's answers to this
799
+ body are pinned in `backend/tests/test_fleet_observations.py`."""
800
+ import io
801
+ import urllib.error
802
+ import urllib.request
803
+
804
+ sent: list = []
805
+ answer = {"code": 404}
806
+
807
+ def fake_urlopen(req, timeout=None):
808
+ sent.append(json.loads(req.data))
809
+ raise urllib.error.HTTPError(req.full_url, answer["code"], "x", {}, io.BytesIO(b""))
810
+
811
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
812
+ assert doctor.can_supervise("http://gb.invalid", "gb_sk_X") is True
813
+ assert sent[-1] == doctor.PROBE_BODY and sent[-1]["item_id"] and sent[-1]["kind"]
814
+ for code, want in ((403, False), (401, False), (422, None), (500, None)):
815
+ answer["code"] = code
816
+ assert doctor.can_supervise("http://gb.invalid", "gb_sk_X") is want, code
817
+ assert doctor.can_supervise("file:///etc/passwd", "gb_sk_X") is None
@@ -153,6 +153,7 @@ def test_the_credential_is_minted_with_no_expiry_and_the_fleet_tier(tmp_path, wi
153
153
  assert body["expires_in_days"] is None, "the delegation credential must not expire"
154
154
  assert "fleet" in body["tool_tiers"]
155
155
  assert body["project_id"] == "core"
156
+ assert body["origin"] == "gban_setup"
156
157
 
157
158
 
158
159
  def test_the_supervisor_key_travels_in_the_environment_not_the_arguments(tmp_path, wired):
@@ -302,7 +303,9 @@ def test_a_configured_key_that_cannot_delegate_is_replaced_and_the_old_one_named
302
303
  # The configured key authenticates fine — it simply cannot see `delegate`. So it is
303
304
  # replaced rather than patched, and the one it replaces is named rather than orphaned
304
305
  # silently: `tool_tiers` are fixed at mint and no route changes them.
305
- assert any("still live" in l["detail"] for l in lines)
306
+ assert not any("still live" in l["detail"] for l in lines)
307
+ assert any("no longer works" in l["detail"] and "if the old one still works" in l["detail"]
308
+ for l in lines)
306
309
  assert len(server.minted) == 1
307
310
  servers = json.loads(home.read_text())["projects"][str(repo.resolve())]["mcpServers"]
308
311
  assert servers["graphban"]["headers"]["X-API-Key"] != "gb_sk_weak"
@@ -536,6 +539,7 @@ def test_setup_installs_every_shipped_skill(tmp_path, wired):
536
539
  assert "watch-wave" in slugs
537
540
  assert "graphban-grill" in slugs
538
541
  assert "graphban-wave" in slugs
542
+ assert "graphban-answer" in slugs
539
543
  for slug in slugs:
540
544
  body = setup_mod.skill_source(slug).read_text()
541
545
  for rel in setup_mod.SKILL_DEST_DIRS:
@@ -635,6 +639,7 @@ def test_the_skill_ships_in_the_wheel(tmp_path):
635
639
  assert any(n.endswith("skills/watch-wave/SKILL.md") for n in names), names[:20]
636
640
  assert any(n.endswith("skills/graphban-grill/SKILL.md") for n in names), names[:20]
637
641
  assert any(n.endswith("skills/graphban-wave/SKILL.md") for n in names), names[:20]
642
+ assert any(n.endswith("skills/graphban-answer/SKILL.md") for n in names), names[:20]
638
643
 
639
644
 
640
645
  def test_graphban_wave_drains_one_prd_and_does_not_file_tasks():
@@ -647,6 +652,8 @@ def test_graphban_wave_drains_one_prd_and_does_not_file_tasks():
647
652
  assert "--dry-run" in body
648
653
  assert "gbfleet service install" in body
649
654
  assert "graphban-prd" in body
655
+ assert "graphban-answer" in body
656
+ assert "Do not attach a spawn watcher" in body
650
657
 
651
658
 
652
659
  def test_watch_wave_names_the_scheduler_and_cancels_on_idle():
@@ -659,6 +666,36 @@ def test_watch_wave_names_the_scheduler_and_cancels_on_idle():
659
666
  assert "scheduler_delete" in body
660
667
  assert "Do not wait to be asked" in body
661
668
  assert "in_progress" in body
669
+ assert "clear a wait" in body
670
+ assert "graphban-answer" in body
671
+
672
+
673
+ def test_graphban_answer_quotes_a_decision_and_leaves_the_human_acts():
674
+ """A mid-build question is a wait:decision the parent may clear from a
675
+ quoted sentence. Clearing a secret, or recording a grill turn, is a
676
+ different act. The child files the wait; the drain attaches the loop."""
677
+ body = " ".join(setup_mod.skill_source("graphban-answer").read_text(encoding="utf-8").split())
678
+ assert "A child does not run this" in body
679
+ assert "wait:decision" in body
680
+ assert "wait:secret" in body
681
+ assert "wait:access" in body
682
+ assert "wait:deploy" in body
683
+ assert "wait:merge" in body
684
+ assert "include_candidates" in body
685
+ assert "Do not `answer_grill`" in body
686
+ assert "Do not `update_prd`" in body
687
+ assert "search_memory" in body
688
+ assert "Do not `publish_memory`" in body
689
+ assert "scheduler_create" in body
690
+ assert "project_id" in body
691
+ assert "one step" in body
692
+ assert "related_work" in body
693
+ assert 'status=done, tags=["wait:decision"]' in body
694
+ work = setup_mod.skill_source("graphban-work").read_text(encoding="utf-8")
695
+ assert "wait:decision" in work
696
+ assert "graphban-answer" in work
697
+ delegation = setup_mod.skill_source("graphban-delegation").read_text(encoding="utf-8")
698
+ assert "graphban-answer" in delegation
662
699
 
663
700
 
664
701
  # ---- installing the supervisor -------------------------------------------------------------------
@@ -1171,3 +1208,49 @@ def test_setup_refuses_to_write_a_key_that_would_not_be_ignored(tmp_path, wired,
1171
1208
  assert code == 1
1172
1209
  assert not (repo / ".mcp.json").exists(), "wrote a key git would commit"
1173
1210
  assert any("would not be gitignored" in l["detail"] for l in lines)
1211
+
1212
+
1213
+ def test_web_agent_list_is_generated_from_setup_agents():
1214
+ """The quick-start picker's gban-route list (GRPH-1362 / G9) is exported, not copied."""
1215
+ import importlib.util
1216
+ import pathlib
1217
+
1218
+ root = pathlib.Path(__file__).resolve().parents[2]
1219
+ spec = importlib.util.spec_from_file_location("gen_gban_agents", root / "scripts" / "gen_gban_agents.py")
1220
+ mod = importlib.util.module_from_spec(spec)
1221
+ spec.loader.exec_module(mod)
1222
+ assert mod.OUT.read_text() == mod.render(), "run scripts/gen_gban_agents.py"
1223
+
1224
+
1225
+ def test_quickstart_commands_parse_with_the_cli_parser():
1226
+ """Every gban command the quick-start dialog renders parses with `gban`'s own parser.
1227
+
1228
+ Bounced once: the dialog showed `gban login --server …`, which argparse rejects because
1229
+ `--server` is a top-level option. The strings now come from QUICKSTART_COMMANDS, and this
1230
+ pins them to the parser so they cannot drift again."""
1231
+ import shlex
1232
+
1233
+ from gban.cli import _parser
1234
+ from gban.setup import QUICKSTART_COMMANDS
1235
+
1236
+ gban_cmds = [c for c in QUICKSTART_COMMANDS if c.startswith("gban ")]
1237
+ assert gban_cmds, "the dialog's gban route lost its commands"
1238
+ for template in gban_cmds:
1239
+ argv = shlex.split(template.format(server="https://gb.example.com", project="core"))
1240
+ ns = _parser().parse_args(argv[1:]) # SystemExit(2) here is the bug this guards
1241
+ assert ns.command in {"login", "setup"}
1242
+ if "{server}" in template:
1243
+ assert ns.server == "https://gb.example.com"
1244
+ if "{project}" in template:
1245
+ assert ns.project == "core"
1246
+
1247
+
1248
+ def test_the_old_subcommand_flag_order_is_still_rejected():
1249
+ """Sabotage for the test above: the bounced strings must fail to parse, or it proves nothing."""
1250
+ import pytest
1251
+
1252
+ from gban.cli import _parser
1253
+
1254
+ for argv in (["login", "--server", "https://x"], ["setup", "--project", "core"]):
1255
+ with pytest.raises(SystemExit):
1256
+ _parser().parse_args(argv)
@@ -165,6 +165,7 @@ def test_the_key_is_minted_with_write_as_well_as_gate(tmp_path, wired, ctx):
165
165
  assert set(body["scopes"]) == {"read", "write", "gate"}
166
166
  assert body["project_id"] == "core", "an unpinned gate key attests everywhere"
167
167
  assert body["expires_in_days"] is None
168
+ assert body["origin"] == "gban_swamp_setup"
168
169
 
169
170
 
170
171
  def test_a_key_that_came_back_without_write_fails_here_not_in_ci(tmp_path, wired, ctx):
@@ -204,7 +205,20 @@ def test_an_existing_secret_is_left_alone(tmp_path, wired, ctx):
204
205
 
205
206
  assert code == 0
206
207
  assert server.minted == [], "minted a second gate key over a working one"
207
- assert any("left alone" in l["detail"] for l in lines)
208
+ assert any("left alone" in l["detail"] and "--replace" in l["detail"] for l in lines)
209
+
210
+
211
+ def test_replace_mints_and_overwrites_a_present_secret(tmp_path, wired, ctx):
212
+ repo, adapter = _tree(tmp_path)
213
+ fake = wired(Swamp(vaults=["secrets"], keys=["graphban-api-key"], sources=[str(adapter)]))
214
+ server = Server()
215
+
216
+ lines, code = swamp_mod.setup(ctx(server), URL, "core", repo, adapter, replace=True)
217
+
218
+ assert code == 0
219
+ assert len(server.minted) == 1
220
+ assert server.minted[0]["origin"] == "gban_swamp_setup"
221
+ assert "gb_sk_gate1" in fake.stdin, "the new key was not written to the vault"
208
222
 
209
223
 
210
224
  def test_an_initialised_repo_is_never_reinitialised(tmp_path, wired, ctx):
@@ -124,7 +124,7 @@ wheels = [
124
124
 
125
125
  [[package]]
126
126
  name = "graphban-cli"
127
- version = "0.8.2"
127
+ version = "0.8.4"
128
128
  source = { editable = "." }
129
129
 
130
130
  [package.optional-dependencies]
File without changes