graphban-cli 0.8.7__tar.gz → 0.8.9__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.7 → graphban_cli-0.8.9}/PKG-INFO +1 -1
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/pyproject.toml +1 -1
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/doctor.py +43 -1
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/playbooks.py +49 -5
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/setup.py +17 -1
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-answer/SKILL.md +5 -5
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_doctor.py +19 -1
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_playbooks.py +132 -7
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_setup.py +21 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/uv.lock +1 -1
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/.gitignore +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/LICENSE +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/README.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/__init__.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/cli.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/client.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/config.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/machine.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-grill/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-orient/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-prd/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-prove/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-review/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-scan/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-ship/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-unstick/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-wave/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/graphban-work/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/skills/watch-wave/SKILL.md +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/stackmap.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/src/gban/swamp.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_acts.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_login_pair.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_machine_report.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_packaging.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_session.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_stackmap.py +0 -0
- {graphban_cli-0.8.7 → graphban_cli-0.8.9}/tests/test_swamp.py +0 -0
|
@@ -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"]
|
|
@@ -651,13 +651,29 @@ def install_skill(repo: Path, slug: str = SKILL_SLUG) -> list[tuple[str, Path]]:
|
|
|
651
651
|
saying so. A dest that differs does not block the other directories."""
|
|
652
652
|
body = skill_source(slug).read_text(encoding="utf-8")
|
|
653
653
|
out = []
|
|
654
|
+
from . import playbooks
|
|
655
|
+
owned = playbooks._read(repo).get("files") or {}
|
|
654
656
|
for dest in skill_dests(repo, slug):
|
|
655
657
|
if dest.exists():
|
|
656
|
-
|
|
658
|
+
if dest.read_text(encoding="utf-8") == body:
|
|
659
|
+
status = "same"
|
|
660
|
+
elif dest.is_file() and owned.get(dest.relative_to(repo).as_posix()) == \
|
|
661
|
+
playbooks._sha(dest.read_bytes()):
|
|
662
|
+
# The server pack wrote this exact body (the receipt says so): it is newer
|
|
663
|
+
# than the wheel's copy, not a local edit, and the pack keeps it fresh.
|
|
664
|
+
status = "same"
|
|
665
|
+
else:
|
|
666
|
+
status = "differs"
|
|
667
|
+
out.append((status, dest))
|
|
657
668
|
continue
|
|
658
669
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
659
670
|
dest.write_text(body, encoding="utf-8")
|
|
660
671
|
out.append(("written", dest))
|
|
672
|
+
# Receipt the files we planted, so the pack refreshes them instead of freezing them as edits.
|
|
673
|
+
planted = {d.relative_to(repo).as_posix(): d.read_bytes() for st, d in out if st == "written"}
|
|
674
|
+
if planted:
|
|
675
|
+
from . import playbooks
|
|
676
|
+
playbooks.record(repo, planted)
|
|
661
677
|
return out
|
|
662
678
|
|
|
663
679
|
|
|
@@ -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"]
|
|
@@ -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)
|
|
@@ -290,3 +306,112 @@ def test_gban_doctor_prints_the_playbooks_line(tmp_path, monkeypatch, capsys):
|
|
|
290
306
|
out = capsys.readouterr()
|
|
291
307
|
assert "playbooks" in out.out and "at abc" in out.out and "just now" not in out.out
|
|
292
308
|
assert "last poll" not in out.err
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --- GRPH-1506: hook files ride the pack, each to its own path -------------------------------
|
|
312
|
+
|
|
313
|
+
HOOK = ".cursor/hooks/gb_stop.py"
|
|
314
|
+
HOOKS_JSON = ".cursor/hooks.json"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _hook_pack(sha_, *extra):
|
|
318
|
+
files = [{"path": SKILL, "kind": "skill", "body": "S1"},
|
|
319
|
+
{"path": HOOK, "kind": "hook", "body": "H1"}, *extra]
|
|
320
|
+
return {"git_sha": sha_, "files": files}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_a_hook_is_written_once_at_its_own_path_and_a_skill_still_fans_out(tmp_path):
|
|
324
|
+
poll(URL, tmp_path, client=Fake({"git_sha": "1"}, _hook_pack("1")), now=0)
|
|
325
|
+
assert (tmp_path / HOOK).read_text() == "H1"
|
|
326
|
+
assert playbooks.targets({"path": HOOK, "kind": "hook"}) == [HOOK]
|
|
327
|
+
assert sorted(receipt(tmp_path)["files"]) == sorted([*COPIES, HOOK])
|
|
328
|
+
for rel in COPIES:
|
|
329
|
+
assert (tmp_path / rel).read_text() == "S1"
|
|
330
|
+
assert not (tmp_path / ".claude/hooks").exists()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_a_foreign_hooks_json_is_left_and_named_while_the_scripts_are_written(tmp_path):
|
|
334
|
+
(tmp_path / ".cursor").mkdir()
|
|
335
|
+
(tmp_path / HOOKS_JSON).write_text("theirs")
|
|
336
|
+
pack = _hook_pack("1", {"path": HOOKS_JSON, "kind": "hook", "body": "{}"})
|
|
337
|
+
r = poll(URL, tmp_path, client=Fake({"git_sha": "1"}, pack), now=0)
|
|
338
|
+
assert (tmp_path / HOOKS_JSON).read_text() == "theirs"
|
|
339
|
+
assert HOOKS_JSON in r["kept"] and HOOKS_JSON in r["note"]
|
|
340
|
+
assert HOOKS_JSON not in receipt(tmp_path)["files"]
|
|
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)
|
|
@@ -571,6 +571,27 @@ def test_an_edited_skill_is_left_alone(tmp_path, wired):
|
|
|
571
571
|
assert other.read_text() == setup_mod.skill_source("watch-wave").read_text()
|
|
572
572
|
|
|
573
573
|
|
|
574
|
+
def test_a_pack_written_skill_that_differs_from_the_bundled_one_is_not_unknown(tmp_path, wired):
|
|
575
|
+
"""GRPH-1543: the pack just wrote a newer skill than the wheel carries. Setup must not
|
|
576
|
+
flag a file the same command wrote."""
|
|
577
|
+
from gban import playbooks
|
|
578
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
579
|
+
repo.mkdir()
|
|
580
|
+
planted = {}
|
|
581
|
+
for rel in setup_mod.SKILL_DEST_DIRS:
|
|
582
|
+
dest = repo / rel / "graphban-delegation" / "SKILL.md"
|
|
583
|
+
dest.parent.mkdir(parents=True)
|
|
584
|
+
dest.write_bytes(b"newer pack copy\n")
|
|
585
|
+
planted[dest.relative_to(repo).as_posix()] = b"newer pack copy\n"
|
|
586
|
+
playbooks.record(repo, planted)
|
|
587
|
+
|
|
588
|
+
lines, _, _ = _run(Server(), repo, home, wired=wired)
|
|
589
|
+
|
|
590
|
+
assert not any("left alone" in l["detail"] for l in lines)
|
|
591
|
+
assert not any(l.get("name") == "skill" and l.get("status") == "UNKNOWN" for l in lines)
|
|
592
|
+
assert dest.read_bytes() == b"newer pack copy\n"
|
|
593
|
+
|
|
594
|
+
|
|
574
595
|
def test_the_skill_tells_the_agent_the_two_things_it_cannot_work_out(tmp_path):
|
|
575
596
|
"""Both are failures that read as something else. A missing tier reads as the tool not
|
|
576
597
|
existing; a missing session reads as something to retry, when the remedy is a person at a
|
|
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
|
|
File without changes
|