graphban-cli 0.8.8__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.
Files changed (42) hide show
  1. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/PKG-INFO +1 -1
  2. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/pyproject.toml +1 -1
  3. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/doctor.py +43 -1
  4. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/playbooks.py +49 -5
  5. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/setup.py +17 -1
  6. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-answer/SKILL.md +5 -5
  7. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_doctor.py +19 -1
  8. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_playbooks.py +99 -7
  9. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_setup.py +21 -0
  10. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/uv.lock +1 -1
  11. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/.gitignore +0 -0
  12. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/LICENSE +0 -0
  13. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/README.md +0 -0
  14. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/__init__.py +0 -0
  15. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/cli.py +0 -0
  16. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/client.py +0 -0
  17. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/config.py +0 -0
  18. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/data/stack_sdks.toml +0 -0
  19. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/gitignore.py +0 -0
  20. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/machine.py +0 -0
  21. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
  22. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-grill/SKILL.md +0 -0
  23. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-orient/SKILL.md +0 -0
  24. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-prd/SKILL.md +0 -0
  25. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-prove/SKILL.md +0 -0
  26. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-review/SKILL.md +0 -0
  27. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-scan/SKILL.md +0 -0
  28. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-ship/SKILL.md +0 -0
  29. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-unstick/SKILL.md +0 -0
  30. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-wave/SKILL.md +0 -0
  31. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/graphban-work/SKILL.md +0 -0
  32. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/skills/watch-wave/SKILL.md +0 -0
  33. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/stackmap.py +0 -0
  34. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/src/gban/swamp.py +0 -0
  35. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_acts.py +0 -0
  36. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_login_pair.py +0 -0
  37. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_login_projects.py +0 -0
  38. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_machine_report.py +0 -0
  39. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_packaging.py +0 -0
  40. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_session.py +0 -0
  41. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_stackmap.py +0 -0
  42. {graphban_cli-0.8.8 → graphban_cli-0.8.9}/tests/test_swamp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.8.8
3
+ Version: 0.8.9
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.8.8"
3
+ version = "0.8.9"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 or file hashes.
132
- base = receipt if receipt.get("server") == url else {}
133
- new = {"server": url, "polled_at": now, "git_sha": base.get("git_sha", ""),
134
- "files": base.get("files", {})}
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
- out.append(("same" if dest.read_text(encoding="utf-8") == body else "differs", dest))
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: evidence on the wait, kind note, the sentence and where it came from. update_item status=done on the wait only.
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. Leave the wait blocked.
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. Marking the wait `done` returns it to `next` when no other unmet dependency remains.
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. `update_item` on the wait: evidence `kind` `note` with that sentence, then `status=done`.
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
- Record nothing until the user answers. Their words are the evidence, then the wait is `done`. The recommendation alone is not the answer.
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", encoding="utf-8")
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": "s/a.md", "body": body},
86
- {"path": "s/b.md", "body": "B"}]}
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 / "s/b.md").write_text("edited")
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 / "s/a.md").read_text() == "A2"
91
- assert (tmp_path / "s/b.md").read_text() == "edited" and "s/b.md" in r["note"]
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": "s/a.md", "body": "A"}]})
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 / "s/a.md").read_text() == "A"
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)
@@ -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
@@ -124,7 +124,7 @@ wheels = [
124
124
 
125
125
  [[package]]
126
126
  name = "graphban-cli"
127
- version = "0.8.8"
127
+ version = "0.8.9"
128
128
  source = { editable = "." }
129
129
 
130
130
  [package.optional-dependencies]
File without changes
File without changes
File without changes