graphban-cli 0.8.8__tar.gz → 0.8.10__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.8 → graphban_cli-0.8.10}/PKG-INFO +1 -1
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/pyproject.toml +1 -1
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/cli.py +28 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/doctor.py +43 -1
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/playbooks.py +49 -5
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/setup.py +27 -4
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-answer/SKILL.md +5 -5
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_doctor.py +19 -1
- graphban_cli-0.8.10/tests/test_fleet_until.py +54 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_playbooks.py +99 -7
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_setup.py +48 -1
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/uv.lock +1 -1
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/.gitignore +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/LICENSE +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/README.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/__init__.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/client.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/config.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/machine.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-grill/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-orient/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-prd/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-prove/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-review/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-scan/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-ship/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-unstick/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-wave/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/graphban-work/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/skills/watch-wave/SKILL.md +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/stackmap.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/src/gban/swamp.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_acts.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_login_pair.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_machine_report.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_packaging.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_session.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_stackmap.py +0 -0
- {graphban_cli-0.8.8 → graphban_cli-0.8.10}/tests/test_swamp.py +0 -0
|
@@ -551,6 +551,30 @@ def cmd_stack(args) -> int:
|
|
|
551
551
|
return 0
|
|
552
552
|
|
|
553
553
|
|
|
554
|
+
ADAPTER_ENV = "GBAN_ADAPTER"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _until_with_adapter(rest: list[str]) -> list[str] | None:
|
|
558
|
+
"""`gbfleet until` requires --adapter; Triage's drain note shows the command without it.
|
|
559
|
+
|
|
560
|
+
Fill it from the flag (already there), $GBAN_ADAPTER, the settings file's `adapter`, or
|
|
561
|
+
`claude` when that binary is on PATH. Otherwise say which value is missing and how to set
|
|
562
|
+
it, rather than letting argparse print usage. Only args before a `--` are ours to read.
|
|
563
|
+
"""
|
|
564
|
+
own = rest[:rest.index("--")] if "--" in rest else rest
|
|
565
|
+
if any(a == "--adapter" or a.startswith("--adapter=") for a in own):
|
|
566
|
+
return rest
|
|
567
|
+
adapter = (os.environ.get(ADAPTER_ENV) or config.settings().get("adapter") or "").strip()
|
|
568
|
+
if not adapter and shutil.which("claude"):
|
|
569
|
+
adapter = "claude"
|
|
570
|
+
if not adapter:
|
|
571
|
+
print(f"{PROG}: `{PROG} fleet until` needs --adapter (the agent vendor to run, e.g. "
|
|
572
|
+
f"claude) and none is configured. Pass --adapter <vendor>, set ${ADAPTER_ENV}, "
|
|
573
|
+
f"or add \"adapter\" to {config.home() / config.SETTINGS_FILE}.", file=sys.stderr)
|
|
574
|
+
return None
|
|
575
|
+
return [rest[0], "--adapter", adapter, *rest[1:]]
|
|
576
|
+
|
|
577
|
+
|
|
554
578
|
def cmd_fleet(args) -> int:
|
|
555
579
|
"""A subprocess, never an import (D5).
|
|
556
580
|
|
|
@@ -576,6 +600,10 @@ def cmd_fleet(args) -> int:
|
|
|
576
600
|
rest = list(args.rest)
|
|
577
601
|
if rest[:1] == ["--"]:
|
|
578
602
|
rest = rest[1:]
|
|
603
|
+
if rest[:1] == ["until"]:
|
|
604
|
+
rest = _until_with_adapter(rest)
|
|
605
|
+
if rest is None:
|
|
606
|
+
return EXIT_REFUSED
|
|
579
607
|
argv = [binary, *rest]
|
|
580
608
|
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
581
609
|
if url and "--server" not in argv:
|
|
@@ -11,6 +11,7 @@ the same reason: "we could not tell" must never render as "nothing is wrong".
|
|
|
11
11
|
"""
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import json
|
|
14
15
|
import os
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
@@ -408,10 +409,51 @@ def playbooks_line(result: dict | None) -> dict | None:
|
|
|
408
409
|
return _line("local", PASS, "playbooks", f"polled just now — {note}")
|
|
409
410
|
|
|
410
411
|
|
|
412
|
+
def harness_entries(repo: Path) -> list[dict]:
|
|
413
|
+
"""Does each harness config that exists still declare the graphban MCP entry?
|
|
414
|
+
|
|
415
|
+
`gban setup` writes the entry and says so once; nothing re-reads it. Remove it and Claude
|
|
416
|
+
Code can no longer reach the ledger while every server-side check stays PASS. A harness
|
|
417
|
+
counts as configured when one of its files exists; a missing file is not checked (never
|
|
418
|
+
set up here), a file without the entry is a FAIL named after the file.
|
|
419
|
+
"""
|
|
420
|
+
import tomllib
|
|
421
|
+
from gban import setup # lazy: setup imports this module
|
|
422
|
+
|
|
423
|
+
server = setup.LEDGER_SERVER
|
|
424
|
+
key = str(repo.resolve())
|
|
425
|
+
|
|
426
|
+
def read(path: Path, toml: bool):
|
|
427
|
+
try:
|
|
428
|
+
text = path.read_text(encoding="utf-8")
|
|
429
|
+
return tomllib.loads(text) if toml else json.loads(text)
|
|
430
|
+
except (OSError, ValueError):
|
|
431
|
+
return None # unreadable counts as lacking the entry
|
|
432
|
+
|
|
433
|
+
claude = [(setup.claude_home(), lambda b: (((b.get("projects") or {}).get(key) or {})
|
|
434
|
+
.get("mcpServers") or {})),
|
|
435
|
+
(repo / ".mcp.json", lambda b: b.get("mcpServers") or {})]
|
|
436
|
+
grok = [(setup.grok_home(), lambda b: b.get("mcp_servers") or {}),
|
|
437
|
+
(repo / ".grok" / "config.toml", lambda b: b.get("mcp_servers") or {})]
|
|
438
|
+
out: list[dict] = []
|
|
439
|
+
for harness, files, toml in (("claude", claude, False), ("grok", grok, True)):
|
|
440
|
+
present = [(p, pick) for p, pick in files if p.exists()]
|
|
441
|
+
if not present:
|
|
442
|
+
continue
|
|
443
|
+
if any(server in pick(read(p, toml) or {}) for p, pick in present):
|
|
444
|
+
out.append(_line("local", PASS, f"{harness} mcp", f"{server} entry present"))
|
|
445
|
+
else:
|
|
446
|
+
names = ", ".join(str(p) for p, _ in present)
|
|
447
|
+
out.append(_line("local", FAIL, f"{harness} mcp",
|
|
448
|
+
f"no {server} entry for this project in {names} — "
|
|
449
|
+
f"{harness} cannot reach the ledger; run `gban setup`"))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
411
453
|
def run(url: str, project: str, api_key: str = "",
|
|
412
454
|
repo: Path | None = None) -> tuple[list[dict], int]:
|
|
413
455
|
lines = (ledger(url, project) + local(url, project, api_key)
|
|
414
|
-
+ gitignore.check(repo or Path.cwd()))
|
|
456
|
+
+ gitignore.check(repo or Path.cwd()) + harness_entries(repo or Path.cwd()))
|
|
415
457
|
worst = max((SEVERITY[l["status"]] for l in lines), default=0)
|
|
416
458
|
# FAIL exits 1, UNKNOWN exits 0: "could not check" is not "broken", and a doctor that
|
|
417
459
|
# failed a script because a laptop lacked `gbfleet` would stop being run.
|
|
@@ -6,6 +6,7 @@ Every `gban` command may poll `/health`; `gban update` always does. The receipt
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import fnmatch
|
|
9
10
|
import hashlib
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
@@ -24,6 +25,31 @@ INTERVAL = 3600
|
|
|
24
25
|
SKILL_DIRS = (".claude/skills", ".grok/skills", ".agents/skills")
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
#: What a pack entry may write, per `kind` (GRPH-P65 table). `*` is one path segment. Anything
|
|
29
|
+
#: else (settings, AGENTS.md, MCP config, .git/hooks) is refused and the whole pack is dropped.
|
|
30
|
+
ALLOWED = {
|
|
31
|
+
"skill": tuple(f"{b}/*/SKILL.md" for b in SKILL_DIRS),
|
|
32
|
+
"loop": (".cursor/agents/*", ".claude/agents/*", ".codex/agents/*", ".cursor/rules/*.mdc"),
|
|
33
|
+
"playbook": (".cursor/plugins/graphban/skills/*/SKILL.md",),
|
|
34
|
+
"hook": (".cursor/hooks.json", ".cursor/hooks/gb_after_file_edit.py",
|
|
35
|
+
".cursor/hooks/gb_claim_manifest.py", ".cursor/hooks/gb_session_start.py",
|
|
36
|
+
".cursor/hooks/gb_stop.py", ".grok/hooks/graphban-claim-manifest.json"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def allowed(kind: object, rel: object) -> bool:
|
|
41
|
+
"""True when `rel` is a path the pack may write for this `kind`."""
|
|
42
|
+
if not isinstance(kind, str) or kind not in ALLOWED or not isinstance(rel, str):
|
|
43
|
+
return False
|
|
44
|
+
parts = rel.split("/")
|
|
45
|
+
for pat in ALLOWED[kind]:
|
|
46
|
+
want = pat.split("/")
|
|
47
|
+
if len(want) == len(parts) and all(
|
|
48
|
+
fnmatch.fnmatchcase(g, w) and (w != "*" or g) for g, w in zip(parts, want)):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
27
53
|
def targets(entry: dict) -> list[str]:
|
|
28
54
|
"""Every checkout path one pack entry is written to."""
|
|
29
55
|
rel = entry.get("path")
|
|
@@ -61,6 +87,17 @@ def _sha(data: bytes) -> str:
|
|
|
61
87
|
return hashlib.sha256(data).hexdigest()
|
|
62
88
|
|
|
63
89
|
|
|
90
|
+
def record(repo: Path, written: dict[str, bytes]) -> None:
|
|
91
|
+
"""Note files this tool wrote outside a poll (setup's skills), so a later pack replaces them
|
|
92
|
+
like any file we wrote. Keeps every other receipt field."""
|
|
93
|
+
receipt = _read(repo)
|
|
94
|
+
files = dict(receipt.get("files") or {})
|
|
95
|
+
for rel, data in written.items():
|
|
96
|
+
files[rel] = _sha(data)
|
|
97
|
+
receipt["files"] = files
|
|
98
|
+
_save(repo, receipt)
|
|
99
|
+
|
|
100
|
+
|
|
64
101
|
def when(ts: float) -> str:
|
|
65
102
|
return time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime(ts))
|
|
66
103
|
|
|
@@ -128,14 +165,17 @@ def poll(url: str, repo: Path, *, force: bool = False, client: Client | None = N
|
|
|
128
165
|
if not isinstance(health, dict):
|
|
129
166
|
return {"polled": False, "failed": True, "note": "unreadable /health"}
|
|
130
167
|
sha = str(health.get("git_sha") or "").strip()
|
|
131
|
-
# A different server never inherits the old server's applied sha
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
168
|
+
# A different server never inherits the old server's stamp or applied sha. File hashes
|
|
169
|
+
# describe what this tool wrote to disk, whichever server sent it, so they stay owned.
|
|
170
|
+
same = receipt.get("server") in (url, None)
|
|
171
|
+
base = {"git_sha": receipt.get("git_sha", "") if same else "",
|
|
172
|
+
"files": receipt.get("files", {})}
|
|
173
|
+
new = {"server": url, "polled_at": now, "git_sha": base["git_sha"],
|
|
174
|
+
"files": base["files"]}
|
|
135
175
|
if not sha:
|
|
136
176
|
_save(repo, new)
|
|
137
177
|
return {"polled": True, "polled_at": now, "note": "deployment is unstamped (empty git_sha)"}
|
|
138
|
-
if sha == base.get("git_sha"):
|
|
178
|
+
if sha == base.get("git_sha") and not force:
|
|
139
179
|
_save(repo, new)
|
|
140
180
|
return {"polled": True, "polled_at": now, "git_sha": sha, "note": f"current at {sha[:12]}"}
|
|
141
181
|
try:
|
|
@@ -170,6 +210,10 @@ def poll(url: str, repo: Path, *, force: bool = False, client: Client | None = N
|
|
|
170
210
|
or ".." in Path(rel).parts or Path(rel).is_absolute():
|
|
171
211
|
return {"polled": False, "failed": True,
|
|
172
212
|
"note": "unreadable pack; wrote nothing"}
|
|
213
|
+
if not allowed(entry.get("kind"), rel):
|
|
214
|
+
return {"polled": False, "failed": True,
|
|
215
|
+
"note": f"pack names a path outside its allowed directories: {rel}; "
|
|
216
|
+
"wrote nothing"}
|
|
173
217
|
plan[rel] = body.encode("utf-8")
|
|
174
218
|
applied = apply(repo, plan, dict(base.get("files", {})))
|
|
175
219
|
owned, written, kept = applied["files"], applied["written"], applied["kept"]
|
|
@@ -425,6 +425,12 @@ def verify(url: str, key: str, project: str) -> list[dict]:
|
|
|
425
425
|
f"{project} is writable" if project in writable else
|
|
426
426
|
f"the key cannot write {project} — you can read it but not write it, so "
|
|
427
427
|
"an agent on this key can claim nothing"))
|
|
428
|
+
if "key_project_id" in ctx and ctx["key_project_id"] != project:
|
|
429
|
+
# `gbfleet until` refuses a key with no project to bind its minted-by check to
|
|
430
|
+
# (GRPH-1658), so a reused cross-project key fails the Triage drain command.
|
|
431
|
+
out.append(_line("ledger", FAIL, "scope",
|
|
432
|
+
"the key is not scoped to this project, and `gban fleet until` "
|
|
433
|
+
"refuses an unscoped key"))
|
|
428
434
|
missing = {_tier_id(t) for t in (ctx.get("missing_tiers") or [])}
|
|
429
435
|
out.append(_line("ledger", PASS if "fleet" not in missing else FAIL, "fleet tools",
|
|
430
436
|
"delegate and mint_enrolment are in the manifest" if "fleet" not in missing
|
|
@@ -495,9 +501,10 @@ def run(client: Client, url: str, project: str, repo: Path, *, scope: str = "use
|
|
|
495
501
|
# A key that cannot do the job is replaced, never patched: `tool_tiers` are fixed at
|
|
496
502
|
# mint and there is no route that changes them.
|
|
497
503
|
lines.append(_line("config", UNKNOWN, "credential",
|
|
498
|
-
"the configured credential no longer works (revoked,
|
|
499
|
-
"delegate
|
|
500
|
-
"works, remove it in
|
|
504
|
+
"the configured credential no longer works (revoked, cannot "
|
|
505
|
+
"delegate, or not scoped to this project), so a project-scoped "
|
|
506
|
+
"one is being minted; if the old one still works, remove it in "
|
|
507
|
+
"Settings → API keys"))
|
|
501
508
|
if not key:
|
|
502
509
|
try:
|
|
503
510
|
minted = mint(client, project)
|
|
@@ -651,13 +658,29 @@ def install_skill(repo: Path, slug: str = SKILL_SLUG) -> list[tuple[str, Path]]:
|
|
|
651
658
|
saying so. A dest that differs does not block the other directories."""
|
|
652
659
|
body = skill_source(slug).read_text(encoding="utf-8")
|
|
653
660
|
out = []
|
|
661
|
+
from . import playbooks
|
|
662
|
+
owned = playbooks._read(repo).get("files") or {}
|
|
654
663
|
for dest in skill_dests(repo, slug):
|
|
655
664
|
if dest.exists():
|
|
656
|
-
|
|
665
|
+
if dest.read_text(encoding="utf-8") == body:
|
|
666
|
+
status = "same"
|
|
667
|
+
elif dest.is_file() and owned.get(dest.relative_to(repo).as_posix()) == \
|
|
668
|
+
playbooks._sha(dest.read_bytes()):
|
|
669
|
+
# The server pack wrote this exact body (the receipt says so): it is newer
|
|
670
|
+
# than the wheel's copy, not a local edit, and the pack keeps it fresh.
|
|
671
|
+
status = "same"
|
|
672
|
+
else:
|
|
673
|
+
status = "differs"
|
|
674
|
+
out.append((status, dest))
|
|
657
675
|
continue
|
|
658
676
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
659
677
|
dest.write_text(body, encoding="utf-8")
|
|
660
678
|
out.append(("written", dest))
|
|
679
|
+
# Receipt the files we planted, so the pack refreshes them instead of freezing them as edits.
|
|
680
|
+
planted = {d.relative_to(repo).as_posix(): d.read_bytes() for st, d in out if st == "written"}
|
|
681
|
+
if planted:
|
|
682
|
+
from . import playbooks
|
|
683
|
+
playbooks.record(repo, planted)
|
|
661
684
|
return out
|
|
662
685
|
|
|
663
686
|
|
|
@@ -41,10 +41,10 @@ Sources, in order. A later source does not override an earlier one. Quote the se
|
|
|
41
41
|
|
|
42
42
|
Leave wait:secret, wait:access, wait:deploy, and wait:merge blocked. Name them.
|
|
43
43
|
|
|
44
|
-
A quote:
|
|
44
|
+
A quote: answer_wait(wait, answer, source={kind: session|prd|memory|decision, ref, quote}). ref is the PRD key, shard id or decision key; it must resolve. It stores the answer and releases the original. Never update_item the wait to done: that is refused without a stored answer.
|
|
45
45
|
Do not update_prd. Do not answer_grill. Do not move the original item. Do not publish_memory.
|
|
46
46
|
|
|
47
|
-
No quote: print the question, your recommendation, and one line on why the sources do not decide.
|
|
47
|
+
No quote: answer_wait(wait, needs_person=true), then print the question, your recommendation, and one line on why the sources do not decide. The wait stays blocked.
|
|
48
48
|
|
|
49
49
|
Do not spawn, merge, sign_off, bounce, or claim.
|
|
50
50
|
```
|
|
@@ -82,11 +82,11 @@ Silence is ask. A conflict is ask. Two reasonable readings are ask. A new contra
|
|
|
82
82
|
|
|
83
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
84
|
|
|
85
|
-
The original is `a` on that dependency link. You are not its builder. Leave it blocked.
|
|
85
|
+
The original is `a` on that dependency link. You are not its builder. Leave it blocked. `answer_wait` returns it to `next` when no other unmet dependency remains. You cannot answer a wait your own agent filed.
|
|
86
86
|
|
|
87
87
|
## Quote
|
|
88
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. `
|
|
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. `answer_wait` with `answer` (or `option`) and `source` `{kind, ref, quote}`: `kind` is `session`, `prd`, `memory` or `decision`; `ref` must resolve for the last three; `quote` is the sentence. Never move a wait to done with `update_item`.
|
|
90
90
|
|
|
91
91
|
A decision that should outlive the item: `add_memory`, scope `global`. Read the returned `status`. Leave a `candidate`. Do not `publish_memory`.
|
|
92
92
|
|
|
@@ -94,7 +94,7 @@ A decision that should outlive the item: `add_memory`, scope `global`. Read the
|
|
|
94
94
|
|
|
95
95
|
One question. The question, your recommendation, and one line on why the sources do not decide it.
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
Call `answer_wait` with `needs_person=true`; it records no answer. When the user answers, call `answer_wait` with their words and `source` kind `session`. The recommendation alone is not the answer.
|
|
98
98
|
|
|
99
99
|
A scheduler fire that must ask prints that question and leaves the wait blocked. The parent session puts it to the user.
|
|
100
100
|
|
|
@@ -724,7 +724,7 @@ def test_doctor_passes_when_the_paths_are_ignored(home, monkeypatch, tmp_path):
|
|
|
724
724
|
repo = tmp_path / "repo"
|
|
725
725
|
_git_init(repo)
|
|
726
726
|
gitignore.ensure(repo)
|
|
727
|
-
(repo / ".mcp.json").write_text("{}\n
|
|
727
|
+
(repo / ".mcp.json").write_text('{"mcpServers": {"graphban": {}}}\n', encoding="utf-8")
|
|
728
728
|
monkeypatch.setattr(doctor, "authenticated", lambda url: _Server())
|
|
729
729
|
monkeypatch.setattr(doctor, "find_supervisor", lambda: "")
|
|
730
730
|
|
|
@@ -815,3 +815,21 @@ def test_can_supervise_posts_a_validating_probe_and_reads_only_403_and_404(monke
|
|
|
815
815
|
answer["code"] = code
|
|
816
816
|
assert doctor.can_supervise("http://gb.invalid", "gb_sk_X") is want, code
|
|
817
817
|
assert doctor.can_supervise("file:///etc/passwd", "gb_sk_X") is None
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def test_doctor_fails_when_the_graphban_entry_is_gone_from_claude_json(home, monkeypatch):
|
|
821
|
+
"""gban setup wrote the entry; removing it must not leave doctor all-PASS."""
|
|
822
|
+
monkeypatch.setattr(doctor, "authenticated", lambda url: _Server())
|
|
823
|
+
monkeypatch.setattr(doctor, "find_supervisor", lambda: "")
|
|
824
|
+
cfg = home / "claude.json"
|
|
825
|
+
cfg.write_text(json.dumps({"projects": {str(home.resolve()): {
|
|
826
|
+
"mcpServers": {"graphban": {"command": "x"}}}}}), encoding="utf-8")
|
|
827
|
+
lines, code = doctor.run("http://gb.invalid", "core", "", repo=home)
|
|
828
|
+
assert [l["status"] for l in lines if l["name"] == "claude mcp"] == ["PASS"]
|
|
829
|
+
|
|
830
|
+
cfg.write_text(json.dumps({"projects": {str(home.resolve()): {"mcpServers": {}}}}),
|
|
831
|
+
encoding="utf-8")
|
|
832
|
+
lines, code = doctor.run("http://gb.invalid", "core", "", repo=home)
|
|
833
|
+
found = [l for l in lines if l["name"] == "claude mcp"]
|
|
834
|
+
assert code == 1 and found[0]["status"] == "FAIL"
|
|
835
|
+
assert str(cfg) in found[0]["detail"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""GRPH-1656 — the command Triage's drain note shows must start a drain as copied."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shlex
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from gban import config
|
|
9
|
+
from gban.cli import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture()
|
|
13
|
+
def seen(tmp_path, monkeypatch):
|
|
14
|
+
monkeypatch.setenv(config.HOME_ENV, str(tmp_path))
|
|
15
|
+
monkeypatch.delenv("GBAN_ADAPTER", raising=False)
|
|
16
|
+
monkeypatch.delenv("GBFLEET_API_KEY", raising=False)
|
|
17
|
+
monkeypatch.setattr("gban.cli.doctor_mod.find_supervisor", lambda: "/usr/bin/gbfleet")
|
|
18
|
+
out = {}
|
|
19
|
+
|
|
20
|
+
class Done:
|
|
21
|
+
returncode = 0
|
|
22
|
+
|
|
23
|
+
monkeypatch.setattr("gban.cli.subprocess.run",
|
|
24
|
+
lambda argv, **kw: (out.update(argv=argv), Done())[1])
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_drain_note_command_reaches_until_with_an_adapter(seen, monkeypatch):
|
|
29
|
+
monkeypatch.setenv("GBAN_ADAPTER", "claude")
|
|
30
|
+
cmd = "gban fleet until --prd P75W-P1" # the string waits.drain_note emits
|
|
31
|
+
assert main(shlex.split(cmd)[1:]) == 0
|
|
32
|
+
argv = seen["argv"]
|
|
33
|
+
assert argv[1] == "until" and argv[argv.index("--adapter") + 1] == "claude"
|
|
34
|
+
assert "--prd" in argv
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_adapter_falls_back_to_claude_on_path(seen, monkeypatch):
|
|
38
|
+
monkeypatch.setattr("gban.cli.shutil.which", lambda n: "/bin/claude" if n == "claude" else None)
|
|
39
|
+
assert main(["fleet", "until", "--prd", "X"]) == 0
|
|
40
|
+
assert "claude" in seen["argv"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_explicit_adapter_is_left_alone(seen, monkeypatch):
|
|
44
|
+
monkeypatch.setenv("GBAN_ADAPTER", "claude")
|
|
45
|
+
assert main(["fleet", "until", "--adapter", "grok", "--prd", "X"]) == 0
|
|
46
|
+
assert seen["argv"].count("--adapter") == 1 and "grok" in seen["argv"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_unknown_adapter_names_what_to_set(seen, monkeypatch, capsys):
|
|
50
|
+
monkeypatch.setattr("gban.cli.shutil.which", lambda n: None)
|
|
51
|
+
assert main(["fleet", "until", "--prd", "X"]) != 0
|
|
52
|
+
err = capsys.readouterr().err
|
|
53
|
+
assert "--adapter" in err and "GBAN_ADAPTER" in err
|
|
54
|
+
assert "argv" not in seen
|
|
@@ -82,21 +82,21 @@ def test_same_sha_does_not_download(tmp_path):
|
|
|
82
82
|
|
|
83
83
|
def test_ownership(tmp_path):
|
|
84
84
|
def pack(sha_, body):
|
|
85
|
-
return {"git_sha": sha_, "files": [{"path": "
|
|
86
|
-
{"path": "
|
|
85
|
+
return {"git_sha": sha_, "files": [{"path": ".claude/agents/a.md", "kind": "loop", "body": body},
|
|
86
|
+
{"path": ".claude/agents/b.md", "kind": "loop", "body": "B"}]}
|
|
87
87
|
poll(URL, tmp_path, client=Fake({"git_sha": "1"}, pack("1", "A1")), now=0)
|
|
88
|
-
(tmp_path / "
|
|
88
|
+
(tmp_path / ".claude/agents/b.md").write_text("edited")
|
|
89
89
|
r = poll(URL, tmp_path, client=Fake({"git_sha": "2"}, pack("2", "A2")), now=5000)
|
|
90
|
-
assert (tmp_path / "
|
|
91
|
-
assert (tmp_path / "
|
|
90
|
+
assert (tmp_path / ".claude/agents/a.md").read_text() == "A2"
|
|
91
|
+
assert (tmp_path / ".claude/agents/b.md").read_text() == "edited" and ".claude/agents/b.md" in r["note"]
|
|
92
92
|
|
|
93
93
|
|
|
94
94
|
def test_pack_is_fetched_with_the_credential_not_the_bare_client(tmp_path):
|
|
95
95
|
bare = Fake({"git_sha": "a"}, Refused(401, "authentication required"))
|
|
96
|
-
authed = Fake(None, {"git_sha": "a", "files": [{"path": "
|
|
96
|
+
authed = Fake(None, {"git_sha": "a", "files": [{"path": ".claude/agents/a.md", "kind": "loop", "body": "A"}]})
|
|
97
97
|
r = playbooks.poll(URL, tmp_path, client=bare, auth=lambda: authed, now=1000)
|
|
98
98
|
assert bare.calls == ["/health"] and authed.calls == ["/api/playbooks"]
|
|
99
|
-
assert (tmp_path / "
|
|
99
|
+
assert (tmp_path / ".claude/agents/a.md").read_text() == "A"
|
|
100
100
|
assert r["polled"] and receipt(tmp_path)["git_sha"] == "a"
|
|
101
101
|
|
|
102
102
|
|
|
@@ -248,6 +248,22 @@ def test_restoring_an_edit_is_delete_and_update(tmp_path):
|
|
|
248
248
|
assert (tmp_path / LOOP).read_text() == "L1" and LOOP in r["written"]
|
|
249
249
|
|
|
250
250
|
|
|
251
|
+
def test_update_restores_a_deleted_edit_when_the_sha_is_unchanged(tmp_path):
|
|
252
|
+
_apply(tmp_path, "1", 0)
|
|
253
|
+
(tmp_path / LOOP).write_text("mine")
|
|
254
|
+
r = _apply(tmp_path, "2", 5000, loop="L2")
|
|
255
|
+
assert r["kept"] == [LOOP] and (tmp_path / LOOP).read_text() == "mine"
|
|
256
|
+
(tmp_path / LOOP).unlink()
|
|
257
|
+
# An ordinary poll past the hour at the same sha downloads nothing.
|
|
258
|
+
fake = Fake({"git_sha": "2"}, _pack("2", loop="L2"))
|
|
259
|
+
poll(URL, tmp_path, client=fake, now=10000)
|
|
260
|
+
assert not (tmp_path / LOOP).exists()
|
|
261
|
+
# `gban update` (force) applies even though the sha is unchanged.
|
|
262
|
+
r = poll(URL, tmp_path, client=fake, now=10001, force=True)
|
|
263
|
+
assert (tmp_path / LOOP).read_text() == "L2" and LOOP in r["written"]
|
|
264
|
+
assert receipt(tmp_path)["files"][LOOP] == sha("L2")
|
|
265
|
+
|
|
266
|
+
|
|
251
267
|
def test_a_path_the_pack_dropped_is_left_on_disk_and_named(tmp_path):
|
|
252
268
|
extra = [{"path": ".claude/agents/gb-old.md", "kind": "loop", "body": "old"}]
|
|
253
269
|
_apply(tmp_path, "1", 0, extra=extra)
|
|
@@ -323,3 +339,79 @@ def test_a_foreign_hooks_json_is_left_and_named_while_the_scripts_are_written(tm
|
|
|
323
339
|
assert HOOKS_JSON in r["kept"] and HOOKS_JSON in r["note"]
|
|
324
340
|
assert HOOKS_JSON not in receipt(tmp_path)["files"]
|
|
325
341
|
assert (tmp_path / HOOK).read_text() == "H1"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _planted(tmp_path, monkeypatch):
|
|
345
|
+
from gban import setup as setup_mod
|
|
346
|
+
src = tmp_path / "wheel" / "SKILL.md"
|
|
347
|
+
src.parent.mkdir()
|
|
348
|
+
src.write_text("wheel v1")
|
|
349
|
+
monkeypatch.setattr(setup_mod, "skill_source", lambda slug="graphban-work": src)
|
|
350
|
+
repo = tmp_path / "repo"
|
|
351
|
+
repo.mkdir()
|
|
352
|
+
setup_mod.install_skill(repo, "graphban-work")
|
|
353
|
+
return repo
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_a_skill_setup_planted_is_refreshed_by_the_pack(tmp_path, monkeypatch):
|
|
357
|
+
repo = _planted(tmp_path, monkeypatch)
|
|
358
|
+
assert receipt(repo)["files"][SKILL] == sha("wheel v1")
|
|
359
|
+
r = _apply(repo, "2", 5000, skill="S2")
|
|
360
|
+
assert r["kept"] == []
|
|
361
|
+
for rel in COPIES:
|
|
362
|
+
assert (repo / rel).read_text() == "S2"
|
|
363
|
+
assert receipt(repo)["files"][rel] == sha("S2")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_a_skill_setup_planted_then_edited_is_left_and_named(tmp_path, monkeypatch):
|
|
367
|
+
repo = _planted(tmp_path, monkeypatch)
|
|
368
|
+
(repo / SKILL).write_text("mine")
|
|
369
|
+
r = _apply(repo, "2", 5000, skill="S2")
|
|
370
|
+
assert r["kept"] == [SKILL]
|
|
371
|
+
assert (repo / SKILL).read_text() == "mine"
|
|
372
|
+
assert (repo / COPIES[2]).read_text() == "S2"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_switching_server_keeps_file_ownership(tmp_path):
|
|
376
|
+
other = "http://other.example"
|
|
377
|
+
poll(URL, tmp_path, client=Fake({"git_sha": "1"}, _pack("1")), now=0)
|
|
378
|
+
poll(other, tmp_path, client=Fake({"git_sha": "1"}, _pack("1")), now=10)
|
|
379
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "2"}, _pack("2", skill="S2")), now=20)
|
|
380
|
+
assert r["polled"] is True and not r.get("kept")
|
|
381
|
+
for rel in COPIES:
|
|
382
|
+
assert (tmp_path / rel).read_text() == "S2"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_switching_server_still_leaves_a_local_edit(tmp_path):
|
|
386
|
+
other = "http://other.example"
|
|
387
|
+
poll(URL, tmp_path, client=Fake({"git_sha": "1"}, _pack("1")), now=0)
|
|
388
|
+
(tmp_path / LOOP).write_text("mine")
|
|
389
|
+
poll(other, tmp_path, client=Fake({"git_sha": "1"}, _pack("1")), now=10)
|
|
390
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "2"}, _pack("2", loop="L2")), now=20)
|
|
391
|
+
assert (tmp_path / LOOP).read_text() == "mine" and r["kept"] == [LOOP]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# --- GRPH-1534: the client only writes the pack's allowed directories ----------------------
|
|
395
|
+
|
|
396
|
+
@pytest.mark.parametrize("kind,path", [
|
|
397
|
+
("loop", ".claude/settings.json"), ("loop", "AGENTS.md"), ("loop", "CLAUDE.md"),
|
|
398
|
+
("loop", ".git/hooks/pre-commit"), ("loop", ".mcp.json"),
|
|
399
|
+
("skill", ".claude/skills/x/other.md"), ("loop", ".claude/agents/a/b.md"),
|
|
400
|
+
("hook", ".git/hooks/pre-commit"), ("bogus", ".claude/agents/ok.md"), (None, LOOP),
|
|
401
|
+
])
|
|
402
|
+
def test_a_path_outside_the_allowlist_writes_nothing(tmp_path, kind, path):
|
|
403
|
+
pack = _pack("1")
|
|
404
|
+
pack["files"].append({"path": path, "kind": kind, "body": "x"})
|
|
405
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "1"}, pack), now=0)
|
|
406
|
+
assert r.get("failed")
|
|
407
|
+
if kind in ("loop", "hook"):
|
|
408
|
+
assert path in r["note"]
|
|
409
|
+
assert not any(p.is_file() for p in tmp_path.rglob("*"))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_the_real_pack_passes_the_allowlist():
|
|
413
|
+
from pathlib import Path
|
|
414
|
+
real = Path(__file__).resolve().parents[2] / "backend/scripts/gban_playbooks.json"
|
|
415
|
+
for e in json.loads(real.read_text())["files"]:
|
|
416
|
+
for rel in playbooks.targets(e):
|
|
417
|
+
assert playbooks.allowed(e["kind"], rel), (e["kind"], rel)
|
|
@@ -59,7 +59,8 @@ class Server:
|
|
|
59
59
|
"""Mints, and answers `get_context` as the deployed one does."""
|
|
60
60
|
|
|
61
61
|
def __init__(self, *, writable=("core",), missing=(), refuse=None, issued=(),
|
|
62
|
-
missing_for=None):
|
|
62
|
+
missing_for=None, scope_for=None):
|
|
63
|
+
self.scope_for = dict(scope_for or {})
|
|
63
64
|
self.writable, self.missing, self.refuse = list(writable), list(missing), refuse
|
|
64
65
|
self.missing_for = dict(missing_for or {})
|
|
65
66
|
self.minted = []
|
|
@@ -92,6 +93,7 @@ def _context(server):
|
|
|
92
93
|
raise Refused(401, "invalid api key", "")
|
|
93
94
|
missing = server.missing_for.get(self.api_key, server.missing)
|
|
94
95
|
ctx = {"project_id": "core", "writable_projects": server.writable,
|
|
96
|
+
"key_project_id": server.scope_for.get(self.api_key, "core"),
|
|
95
97
|
"missing_tiers": [{"tier": t} for t in missing]}
|
|
96
98
|
return {"result": {"content": [{"text": json.dumps(ctx)}]}}
|
|
97
99
|
return call
|
|
@@ -571,6 +573,27 @@ def test_an_edited_skill_is_left_alone(tmp_path, wired):
|
|
|
571
573
|
assert other.read_text() == setup_mod.skill_source("watch-wave").read_text()
|
|
572
574
|
|
|
573
575
|
|
|
576
|
+
def test_a_pack_written_skill_that_differs_from_the_bundled_one_is_not_unknown(tmp_path, wired):
|
|
577
|
+
"""GRPH-1543: the pack just wrote a newer skill than the wheel carries. Setup must not
|
|
578
|
+
flag a file the same command wrote."""
|
|
579
|
+
from gban import playbooks
|
|
580
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
581
|
+
repo.mkdir()
|
|
582
|
+
planted = {}
|
|
583
|
+
for rel in setup_mod.SKILL_DEST_DIRS:
|
|
584
|
+
dest = repo / rel / "graphban-delegation" / "SKILL.md"
|
|
585
|
+
dest.parent.mkdir(parents=True)
|
|
586
|
+
dest.write_bytes(b"newer pack copy\n")
|
|
587
|
+
planted[dest.relative_to(repo).as_posix()] = b"newer pack copy\n"
|
|
588
|
+
playbooks.record(repo, planted)
|
|
589
|
+
|
|
590
|
+
lines, _, _ = _run(Server(), repo, home, wired=wired)
|
|
591
|
+
|
|
592
|
+
assert not any("left alone" in l["detail"] for l in lines)
|
|
593
|
+
assert not any(l.get("name") == "skill" and l.get("status") == "UNKNOWN" for l in lines)
|
|
594
|
+
assert dest.read_bytes() == b"newer pack copy\n"
|
|
595
|
+
|
|
596
|
+
|
|
574
597
|
def test_the_skill_tells_the_agent_the_two_things_it_cannot_work_out(tmp_path):
|
|
575
598
|
"""Both are failures that read as something else. A missing tier reads as the tool not
|
|
576
599
|
existing; a missing session reads as something to retry, when the remedy is a person at a
|
|
@@ -1434,3 +1457,27 @@ def test_the_image_manifest_lists_the_shipped_skills():
|
|
|
1434
1457
|
manifest = json.loads((root / "backend/scripts/gban_skills.json").read_text())
|
|
1435
1458
|
shipped = {p.name for p in (root / "cli/src/gban/skills").iterdir() if p.is_dir()}
|
|
1436
1459
|
assert set(manifest["skills"]) == shipped
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def test_unscoped_configured_key_is_replaced_by_a_project_scoped_mint(tmp_path, wired):
|
|
1463
|
+
"""GRPH-1658: `until` refuses an unscoped key, so setup must not reuse one."""
|
|
1464
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
1465
|
+
repo.mkdir()
|
|
1466
|
+
server = Server(issued={"gb_sk_old"}, scope_for={"gb_sk_old": None})
|
|
1467
|
+
home.write_text(json.dumps({"projects": {str(repo.resolve()): {"mcpServers": {
|
|
1468
|
+
"graphban": {"type": "http", "url": URL, "headers": {"X-API-Key": "gb_sk_old"}}}}}}))
|
|
1469
|
+
lines, code, made = _run(server, repo, home, wired=wired)
|
|
1470
|
+
assert code == 0, lines
|
|
1471
|
+
assert len(server.minted) == 1 and server.minted[0]["project_id"] == "core"
|
|
1472
|
+
assert config.stored_supervisor_key("core") == "gb_sk_new1"
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def test_project_scoped_configured_key_is_still_reused(tmp_path, wired):
|
|
1476
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
1477
|
+
repo.mkdir()
|
|
1478
|
+
server = Server(issued={"gb_sk_old"})
|
|
1479
|
+
home.write_text(json.dumps({"projects": {str(repo.resolve()): {"mcpServers": {
|
|
1480
|
+
"graphban": {"type": "http", "url": URL, "headers": {"X-API-Key": "gb_sk_old"}}}}}}))
|
|
1481
|
+
lines, code, made = _run(server, repo, home, wired=wired)
|
|
1482
|
+
assert code == 0, lines
|
|
1483
|
+
assert server.minted == [] and made.get("reused")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|