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.
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/.gitignore +4 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/PKG-INFO +3 -2
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/README.md +2 -1
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/pyproject.toml +1 -1
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/cli.py +13 -3
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/doctor.py +90 -10
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/setup.py +23 -3
- graphban_cli-0.8.4/src/gban/skills/graphban-answer/SKILL.md +103 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-delegation/SKILL.md +2 -1
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-review/SKILL.md +2 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-wave/SKILL.md +2 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-work/SKILL.md +6 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/watch-wave/SKILL.md +4 -3
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/swamp.py +6 -4
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_doctor.py +72 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_setup.py +84 -1
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_swamp.py +15 -1
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/uv.lock +1 -1
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/LICENSE +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/__init__.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/client.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/config.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-grill/SKILL.md +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-orient/SKILL.md +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-prd/SKILL.md +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/skills/graphban-scan/SKILL.md +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/src/gban/stackmap.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_acts.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_packaging.py +0 -0
- {graphban_cli-0.8.2 → graphban_cli-0.8.4}/tests/test_session.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
263
|
-
if
|
|
264
|
-
return
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
478
|
-
"
|
|
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
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|