graphban-cli 0.7.1__tar.gz → 0.8.0__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 (30) hide show
  1. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/PKG-INFO +2 -2
  2. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/README.md +1 -1
  3. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/pyproject.toml +22 -1
  4. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/client.py +5 -1
  5. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/setup.py +35 -18
  6. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/skills/graphban-delegation/SKILL.md +10 -1
  7. graphban_cli-0.8.0/src/gban/skills/graphban-orient/SKILL.md +31 -0
  8. graphban_cli-0.8.0/src/gban/skills/graphban-prd/SKILL.md +40 -0
  9. graphban_cli-0.8.0/src/gban/skills/graphban-review/SKILL.md +21 -0
  10. graphban_cli-0.8.0/src/gban/skills/graphban-scan/SKILL.md +23 -0
  11. graphban_cli-0.8.0/src/gban/skills/graphban-work/SKILL.md +29 -0
  12. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/skills/watch-wave/SKILL.md +10 -2
  13. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/stackmap.py +2 -1
  14. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/swamp.py +1 -1
  15. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_session.py +41 -0
  16. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_setup.py +10 -3
  17. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/.gitignore +0 -0
  18. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/LICENSE +0 -0
  19. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/__init__.py +0 -0
  20. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/cli.py +0 -0
  21. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/config.py +0 -0
  22. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/data/stack_sdks.toml +0 -0
  23. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/doctor.py +0 -0
  24. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/src/gban/gitignore.py +0 -0
  25. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_acts.py +0 -0
  26. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_doctor.py +0 -0
  27. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_login_projects.py +0 -0
  28. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_packaging.py +0 -0
  29. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_stackmap.py +0 -0
  30. {graphban_cli-0.7.1 → graphban_cli-0.8.0}/tests/test_swamp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.7.1
3
+ Version: 0.8.0
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
@@ -130,7 +130,7 @@ project is not a mistake anybody notices quickly.
130
130
  `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
131
131
  into every parent harness that would actually read them, stores that key next to the login
132
132
  session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
133
- drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
133
+ drops the shipped skills into `.claude/skills/` and `.agents/skills/`, and then **verifies** rather than
134
134
  asserting: it asks the new credential what it can actually see. Restart the session that will
135
135
  call `delegate`/`spawn` afterwards — MCP servers are read at startup.
136
136
 
@@ -115,7 +115,7 @@ project is not a mistake anybody notices quickly.
115
115
  `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
116
116
  into every parent harness that would actually read them, stores that key next to the login
117
117
  session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
118
- drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
118
+ drops the shipped skills into `.claude/skills/` and `.agents/skills/`, and then **verifies** rather than
119
119
  asserting: it asks the new credential what it can actually see. Restart the session that will
120
120
  call `delegate`/`spawn` afterwards — MCP servers are read at startup.
121
121
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.7.1"
3
+ version = "0.8.0"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -43,3 +43,24 @@ packages = ["src/gban"]
43
43
  [tool.pytest.ini_options]
44
44
  testpaths = ["tests"]
45
45
  pythonpath = ["src", "tests"]
46
+
47
+ # Read by `scripts/scan_ci.py` (`bandit -c pyproject.toml -r .`, GRPH-1110). Any other finding
48
+ # is fixed or carries a line-level `# nosec <id>` with its reason.
49
+ [tool.bandit]
50
+ exclude_dirs = [
51
+ # Test code asserts by design (B101) and fakes credentials. It is not in the wheel.
52
+ "tests",
53
+ # A local virtualenv is third-party code, audited by pip-audit, not ours to lint.
54
+ ".venv",
55
+ ]
56
+ skips = [
57
+ # B404 (import subprocess): this is a developer CLI whose job is driving git, gh and the
58
+ # user's own tools. Importing subprocess is the feature, not a smell.
59
+ "B404",
60
+ # B603 (subprocess without shell=True): fires on EVERY argv-list call, which is the safe
61
+ # form. B602 (shell=True) stays on, and that is the injection check that matters.
62
+ "B603",
63
+ # B607 (partial executable path): `git`/`gh` are resolved from the user's own PATH on
64
+ # their own machine on purpose; hardcoding /usr/bin/git breaks every Homebrew install.
65
+ "B607",
66
+ ]
@@ -85,11 +85,15 @@ class Client:
85
85
 
86
86
  # ---- transport ---------------------------------------------------------------------
87
87
  def _open(self, method: str, path: str, body: dict | None, headers: dict) -> tuple[int, dict]:
88
+ if not self.url.startswith(("http://", "https://")):
89
+ # urlopen would read `file:` and friends too, and send the credential with it.
90
+ raise Unreachable(f"could not reach {self.url}: not an http(s) URL")
88
91
  data = json.dumps(body).encode() if body is not None else None
89
92
  request = urllib.request.Request(self.url + path, data=data, method=method,
90
93
  headers={"Content-Type": "application/json", **headers})
91
94
  try:
92
- with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
95
+ # bandit B310: the scheme is checked to be http(s) above.
96
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # nosec B310
93
97
  raw = response.read().decode() or "{}"
94
98
  return response.status, (json.loads(raw) if raw.strip() else {})
95
99
  except urllib.error.HTTPError as exc:
@@ -585,13 +585,19 @@ def supervisor(install: bool) -> list[dict]:
585
585
  # `backend/app/services/artifacts.py` maps the `skill` tier to `.claude/skills/{slug}/SKILL.md`,
586
586
  # `file_additive`, frontmatter with `name` and `description` followed by numbered steps. A skill
587
587
  # written here in a different shape would be one the inventory scan reports and the learning loop
588
- # cannot reason about.
588
+ # cannot reason about. That copy is what Claude, Grok, Cursor, and gbagent read.
589
+ #
590
+ # Qwen 0.23, agy, and dsh do not read `.claude/skills`. They read `<repo>/.agents/skills`
591
+ # (Qwen also reads `.qwen/skills`; one of the two is enough). The same bytes go there.
592
+ # gbagent walks both trees and keeps one copy per `name`.
589
593
  #
590
594
  # Every directory under `skills/` that holds a SKILL.md is installed. Naming only
591
595
  # `graphban-delegation` here is how a second skill (`watch-wave`) would exist in the
592
596
  # wheel and never reach a checkout.
593
597
 
594
598
  SKILL_SLUG = "graphban-delegation"
599
+ #: Inventory path first. The second is the directory the other harnesses share.
600
+ SKILL_DEST_DIRS = (".claude/skills", ".agents/skills")
595
601
 
596
602
 
597
603
  def skills_root() -> Path:
@@ -613,20 +619,28 @@ def shipped_slugs() -> list[str]:
613
619
  )
614
620
 
615
621
 
616
- def install_skill(repo: Path, slug: str = SKILL_SLUG) -> tuple[str, Path]:
617
- """`(status, path)`. Never overwrites: a person may have edited theirs, and a setup command
618
- that silently replaced it would take that edit away without saying so."""
619
- dest = repo / ".claude" / "skills" / slug / "SKILL.md"
622
+ def skill_dests(repo: Path, slug: str) -> list[Path]:
623
+ return [repo / rel / slug / "SKILL.md" for rel in SKILL_DEST_DIRS]
624
+
625
+
626
+ def install_skill(repo: Path, slug: str = SKILL_SLUG) -> list[tuple[str, Path]]:
627
+ """One `(status, path)` per harness directory. Never overwrites: a person may have edited
628
+ theirs, and a setup command that silently replaced it would take that edit away without
629
+ saying so. A dest that differs does not block the other directories."""
620
630
  body = skill_source(slug).read_text(encoding="utf-8")
621
- if dest.exists():
622
- return ("same" if dest.read_text(encoding="utf-8") == body else "differs"), dest
623
- dest.parent.mkdir(parents=True, exist_ok=True)
624
- dest.write_text(body, encoding="utf-8")
625
- return "written", dest
631
+ out = []
632
+ for dest in skill_dests(repo, slug):
633
+ if dest.exists():
634
+ out.append(("same" if dest.read_text(encoding="utf-8") == body else "differs", dest))
635
+ continue
636
+ dest.parent.mkdir(parents=True, exist_ok=True)
637
+ dest.write_text(body, encoding="utf-8")
638
+ out.append(("written", dest))
639
+ return out
626
640
 
627
641
 
628
642
  def skill(repo: Path) -> list[dict]:
629
- """Teach the agent the two calls, once, in the place its harness looks."""
643
+ """Teach the agent the procedure, in every directory a fleet harness reads."""
630
644
  slugs = shipped_slugs()
631
645
  if not slugs:
632
646
  return [_line("local", UNKNOWN, "skill",
@@ -634,17 +648,20 @@ def skill(repo: Path) -> list[dict]:
634
648
  lines = []
635
649
  for slug in slugs:
636
650
  try:
637
- status, dest = install_skill(repo, slug)
651
+ outcomes = install_skill(repo, slug)
638
652
  except OSError as exc:
639
653
  lines.append(_line("local", UNKNOWN, "skill",
640
654
  f"could not write {slug}: {exc}"))
641
655
  continue
642
- if status == "differs":
643
- lines.append(_line("local", UNKNOWN, "skill",
644
- f"{dest} exists and differs from the one shipped here — left alone"))
645
- continue
646
- lines.append(_line("local", PASS, "skill",
647
- f"{dest}" + ("" if status == "written" else " (already current)")))
656
+ current = []
657
+ for status, dest in outcomes:
658
+ if status == "differs":
659
+ lines.append(_line("local", UNKNOWN, "skill",
660
+ f"{dest} exists and differs from the one shipped here — left alone"))
661
+ else:
662
+ current.append(f"{dest}" + ("" if status == "written" else " (already current)"))
663
+ if current:
664
+ lines.append(_line("local", PASS, "skill", "; ".join(current)))
648
665
  return lines
649
666
 
650
667
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: graphban-delegation
3
- description: Hand a Graphban work item to a child agent running on a cheaper model, and enable delegation on a project that does not have it yet. Use when asked to delegate, fan out, spawn a worker, or run a wave.
3
+ description: Hand a Graphban work item to a child agent running on a cheaper model, and enable delegation on a project that does not have it yet. Use when asked to delegate, fan out, spawn a worker, mint seats, retire a wave, or run a wave.
4
4
  ---
5
5
 
6
6
  # Delegating a Graphban item
@@ -186,6 +186,15 @@ chose the item, wrote the brief and picked the tier; review exists to be a secon
186
186
  So a delegated item needs a second agent to review it. Plan for that, or the work stops at
187
187
  `review` with nobody entitled to clear it.
188
188
 
189
+ ## Before a wave of seats
190
+
191
+ `delegate(seat=true)` is one item. Several seats is `mint_enrolment`.
192
+
193
+ - `propose_allocation` first. It names workers and which cluster each would take. Nothing is assigned until `assign_role`.
194
+ - `collision_clusters(holds=true)` before claiming. `predicted: true` means the grouping used inferred areas.
195
+ - `mint_enrolment(review_only=true)` when that seat may review and must not take build work. The child still registers as `worker`. One seat per agent: two agents on one seat cannot review each other.
196
+ - `retire_wave` revokes the seats you minted and releases what they hold. It does not stop the processes. `agents_still_running` are still building against dead seats.
197
+
189
198
  ## When not to use this
190
199
 
191
200
  For a whole backlog, ask the person to run `gbfleet until` instead. It runs the wave with no
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: graphban-orient
3
+ description: >
4
+ Read a Graphban-tracked repo through the code graph before grep or a wide
5
+ file read. Use when starting work, finding where code lives, or before
6
+ search_code, code_neighbors, get_code_map, search_memory, or a filesystem
7
+ crawl. Use when the user runs /graphban-orient.
8
+ user-invocable: true
9
+ ---
10
+
11
+ # Orient
12
+
13
+ Ask the graph before you read the tree. One graph call replaces a crawl.
14
+
15
+ 1. `get_context` once per session. Keep the project id, `missing_tiers`, and gitops. A later call does not refresh a manifest that was fetched at connect.
16
+ 2. Unset gitops (`source` unmeasured, or `control` `linked_unreachable`) is unmeasured. Leave the base branch, the branch pattern, and the release doc alone until a field has a source.
17
+ 3. `search_code(query, top_k=5)` before grep.
18
+ 4. `code_neighbors(path)` before opening a file to learn what calls it or what it calls.
19
+ 5. `search_memory(query, top_k=3)` once per task. Leave `include_candidates` off. A candidate is not a published fact. Writing a shard is `graphban-work`.
20
+ 6. `related_work(id)` once you have an item id.
21
+ 7. Open a file when you have a path and a question.
22
+
23
+ List reads stay on the default `fields=lean`. `search_items` and `get_backlog` return id, title, and status. The one full item read is `get_item_details`, for the item you took. Claiming is `graphban-work`.
24
+
25
+ `get_code_map` takes a `kind` and a small `limit`. With neither, the reply is up to 200 nodes. Check `truncated` when you do call it.
26
+
27
+ Looking around uses the reads above. `describe_code`, `link_code`, and `unlink_code` write the graph.
28
+
29
+ A spec question is `graphban-prd`. `get_prd` is for editing a spec, not for finding a file.
30
+
31
+ `missing_tiers` on `get_context` means those tools were left out of the prompt. The tier decides what is listed. Scope and role decide what a call may do.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: graphban-prd
3
+ description: >
4
+ Author, grill, decompose, or close a Graphban PRD. Use when writing a spec,
5
+ running a grill, answer_grill, update_prd, decompose_prd, request_rebaseline,
6
+ close_prd, or submit_verdict, or when the user runs /graphban-prd.
7
+ user-invocable: true
8
+ ---
9
+
10
+ # PRDs
11
+
12
+ Approval is the grill finishing. `update_prd` cannot set `approved`.
13
+
14
+ Read with one call. `get_prd` when you will edit. `prd_coverage` for gaps: `shaped: false` is not a clean pass, `gaps` means no tasks, `empty_sections` means no substance. `prd_acceptance` with one `view` for delivery. `completeness` is the view that lists baselined sections with nothing delivered.
15
+
16
+ ## Grill
17
+
18
+ `grill_prd` returns the next questions and records nothing. Ask the author. `answer_grill` with their words. `graded: false` means the grader was not asked and this answer was not judged. Call `grill_prd` again while `outstanding` remains. `complete` is the grill finishing, which is what reaches approved.
19
+
20
+ Recording an answer with `update_prd` replaces markdown and leaves the PRD in `draft`, with no turns and no dimensions.
21
+
22
+ ## Edit
23
+
24
+ `update_prd(section=<the ## heading>, body=<that section>)` rewrites one section. A whole-body replace needs `base_hash` from `get_prd`. A moved hash is a refusal. `status` on this call is `draft` or `review`.
25
+
26
+ ## Decompose
27
+
28
+ `decompose_prd` with `create` false first. One proposed task per uncovered section. The task carries the section's framing, so the worker does not also read the PRD. Framing sections are tasks only when `include_prose` is true. `create` true files them once the grill has approved the PRD.
29
+
30
+ ## Rebaseline
31
+
32
+ `request_rebaseline(reason_type, reason)` in your own words. `reason_type` is `learning`, `scope-change`, or `correction`. It reopens the grill. The current baseline governs until a new one is earned. Adding a section is a new PRD. A closed PRD is refused.
33
+
34
+ ## Close
35
+
36
+ `close_prd` is terminal. Every section that `completeness` says has nothing delivered appears once in `dispositions`, as `promoted` (`promote_to` `item` or `prd`) or `deferred` with a reason. After close, further work is a new PRD.
37
+
38
+ ## Verdict
39
+
40
+ `submit_verdict` citations are `{kind, ref}` and must resolve. `kind` is `code` (a code-graph path), `intent` (a baseline section — what an absence finding cites), or `evidence` (an item key). A citation that does not resolve is rejected. Signing work you claimed is flagged.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: graphban-review
3
+ description: >
4
+ Review a Graphban item you did not build: claim_review, sign_off, or bounce.
5
+ Use when reviewing, signing off, or bouncing an item, or when the user runs
6
+ /graphban-review.
7
+ user-invocable: true
8
+ ---
9
+
10
+ # Review
11
+
12
+ Review is a second person. `claim_review` refuses the builder.
13
+
14
+ 1. `claim_review`. `claimed: false` means the item is yours, or it needs `gh` or `human-only` you did not declare. Stop on that item.
15
+ 2. The diff is the `branch` on the claim. Read that branch. A review does not orient the whole repo.
16
+ 3. `sign_off` needs `evidence` and the `commit` SHA you reviewed. It refuses the builder, and it refuses the delegation's `delegated_by`. Above the effort bar it also refuses without a `sabotage` receipt: something broken on purpose and caught by a test. `tests_failed` of zero means the test cannot fail.
17
+ 4. `waive` is `{predicate: reason}` for a check that could not run. A check that ran and failed stays failed. The field does not clear it.
18
+ 5. `bounce(id, reason)` sends it to `next`. The author keeps the worktree for one lease, then the fleet can take it. The person who delegated the item may bounce it. They may not sign it off.
19
+ 6. When the item cites a PRD, call `prd_acceptance` with one `view`. Those views describe what happened. They do not report complete. `get_prd` for the whole body is an edit, not a review. Authoring is `graphban-prd`.
20
+
21
+ `release_item` on a review claim leaves the item in `review`. A bounce is `bounce`.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: graphban-scan
3
+ description: >
4
+ Record Graphban code-health scans without treating a missing scan as clean.
5
+ Use when running a security or health scan, or calling scans_due, scan_status,
6
+ or record_scan, or when the user runs /graphban-scan.
7
+ user-invocable: true
8
+ ---
9
+
10
+ # Scans
11
+
12
+ A missing scan is not a clean scan.
13
+
14
+ 1. `scans_due(head_sha)`. An empty due list is not all-clean. Rows that were `never_scanned`, including security, stay listed.
15
+ 2. `scan_status` for one path, or with no path for every deployable. Pass `head_sha`. States are `clean_at_sha`, `has_findings`, `never_scanned`, or `stale`.
16
+ 3. Run the scanner. `record_scan` needs `scanner_id`, `path`, and `scanned_sha`.
17
+ - `findings: []` is clean at that SHA. A clean row without a SHA cannot age.
18
+ - The same `scanner_id` and path replaces the previous row.
19
+ - The security category is pip-audit, bandit, npm-audit, and semgrep. Other categories are `refactor`, `stub`, `deadcode`, `coverage`, `tests`, and `work`.
20
+ - Category `tests` also sends `tests.passed`, `tests.failed`, and `tests.skipped`.
21
+ - Each finding needs a `summary`. Findings are untrusted. `scanner_id`, `scanner_version`, and the reporter are provenance.
22
+
23
+ A clean row is not a tracker item. A finding that already names `link_ref` points at work that exists.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: graphban-work
3
+ description: >
4
+ Claim and finish one Graphban item: register, claim_cluster or claim_next,
5
+ heartbeat, evidence, release. Use when claiming work, implementing an item,
6
+ updating item status, recording evidence, or when the user runs /graphban-work.
7
+ user-invocable: true
8
+ ---
9
+
10
+ # Work an item
11
+
12
+ Take one item, leave a receipt, hand it to review. Reading the repo is `graphban-orient`: `search_code`, then `code_neighbors`, before grep or a wide read.
13
+
14
+ 1. In a fleet, `register_agent` once before any claim. Read `active_role`, `heartbeat_interval_seconds`, and `tools_off_limits`. A name in `tools_off_limits` is still in the prompt; the server refuses the call. Heartbeat on that interval. Pass `agent_id` from this registration on later calls.
15
+ 2. Fleet: `claim_cluster`. Solo: `claim_next`. `claimed: false` names who holds the area and when it frees. That is the answer. Take different work.
16
+ 3. `get_item_details` once. The `brief` suggests `lane` and `tier` with a basis. You still choose both if you hand it to a child.
17
+ 4. `heartbeat(id, status=<one line>, files=[the paths you are in])` on the lease interval. A heartbeat is not a `fleet_status` poll.
18
+ 5. `update_item` as you go. Evidence appends. Touchpoints union: send the files you actually touched. An empty touchpoint list writes nothing. Status goes to `review`.
19
+ 6. Stopping unfinished: `release_item`. A blocker that is still true tomorrow is status `blocked` with `blocker` set. `backlog` is claimable and will be handed out again. A build claim released without `to_status` goes back to `next`.
20
+
21
+ Signing off is `graphban-review`. The builder does not set `done`.
22
+
23
+ Memory, when the next agent cannot see the lesson in the diff:
24
+
25
+ - `search_memory` first, as in `graphban-orient`.
26
+ - `add_memory`. Read the returned `status`. `candidate` does not appear in later search. A near-duplicate is rejected.
27
+ - `publish_memory` asks a judge. `kept: false` is a normal result.
28
+
29
+ Handing the item to a child is `graphban-delegation`. That is a `delegate`, not a second claim.
@@ -30,8 +30,16 @@ Items: ID1, ID2, …
30
30
  Children if known: AGENT_IDS
31
31
  PRs if known: #N
32
32
 
33
- Call gbfleet__ps, graphban__fleet_status(view="live"), and
34
- graphban__get_item_details for each item.
33
+ Call gbfleet__ps and graphban__fleet_status(view="live").
34
+
35
+ Read each watched item from agents[].holdings (id, status, phase). Live
36
+ omits offline agents and drops the bounced flag.
37
+
38
+ For a watched id that is in no holding, one graphban__search_items call
39
+ with query set to that id and fields=lean. Not get_item_details.
40
+
41
+ Call graphban__get_item_details for a single id only when its status or
42
+ phase changed since the last fire, or when you need the bounce reason.
35
43
 
36
44
  Report <=12 lines: running vs stopped, each item's status/claim/PR, quiet
37
45
  (silent_for / quiet), bounce, or expired-nothing-claimed.
@@ -42,7 +42,8 @@ _SKIP_DIRS = frozenset({
42
42
  ".next", "coverage", "vendor", ".gbfleet", ".hg", ".svn",
43
43
  })
44
44
  _MAX_BYTES = 1_000_000
45
- _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"})
45
+ # bandit B104: hostnames to recognise in a URL, nothing binds to them.
46
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"}) # nosec B104
46
47
  #: Where a local host lands when no table row or single infra node of its kind claims it.
47
48
  #: One node for "this machine", whatever spelling the URL used: `host.docker.internal` is
48
49
  #: Docker's name for the same host `localhost` names, not a service the app calls.
@@ -35,7 +35,7 @@ from gban.doctor import FAIL, PASS, UNKNOWN, _line
35
35
  #: What `docs/swamp.md` §5 names. Changing either renames somebody's existing wiring, so they
36
36
  #: are constants rather than options.
37
37
  VAULT = "secrets"
38
- SECRET_KEY = "graphban-api-key"
38
+ SECRET_KEY = "graphban-api-key" # nosec B105 — the vault entry's NAME, not its value
39
39
  VAULT_TYPE = "local_encryption"
40
40
 
41
41
  #: The adapter repository, a SIBLING of the checkout — `docs/swamp.md` says "sibling, not this
@@ -154,6 +154,47 @@ def test_an_unreachable_server_is_a_different_exit_code_from_an_expired_session(
154
154
  assert "could not reach" in capsys.readouterr().err
155
155
 
156
156
 
157
+ @pytest.mark.parametrize("url", ["file:///etc/passwd", "gb.example.com", "ftp://gb.example.com"])
158
+ def test_a_url_that_is_not_http_is_refused_before_anything_is_opened(url, monkeypatch):
159
+ """GRPH-1112. urlopen reads `file:` too, and would send the credential along. Sabotage:
160
+ delete the scheme check and urlopen is reached, so this fails."""
161
+ from gban.client import Client
162
+
163
+ def opened(*_args, **_kwargs):
164
+ raise AssertionError("urlopen was reached")
165
+
166
+ monkeypatch.setattr("gban.client.urllib.request.urlopen", opened)
167
+ with pytest.raises(Unreachable, match="not an http\\(s\\) URL"):
168
+ Client(url, api_key="KEY").call("GET", "/api/whoami")
169
+
170
+
171
+ @pytest.mark.parametrize("url", ["http://127.0.0.1:8000", "https://gb.example.com/"])
172
+ def test_an_http_or_https_server_url_still_reaches_urlopen(url, monkeypatch):
173
+ """GRPH-1112. The check must not refuse a configured server URL gban is meant to use."""
174
+ from gban.client import Client
175
+ seen = []
176
+
177
+ class _Response:
178
+ status = 200
179
+
180
+ def __enter__(self):
181
+ return self
182
+
183
+ def __exit__(self, *_exc):
184
+ return False
185
+
186
+ def read(self):
187
+ return b'{"ok": true}'
188
+
189
+ def opened(request, timeout):
190
+ seen.append(request.full_url)
191
+ return _Response()
192
+
193
+ monkeypatch.setattr("gban.client.urllib.request.urlopen", opened)
194
+ assert Client(url, api_key="KEY").call("GET", "/api/whoami") == {"ok": True}
195
+ assert seen == [url.rstrip("/") + "/api/whoami"]
196
+
197
+
157
198
  def test_a_refusal_is_printed_in_the_servers_own_words_with_its_hint(home, monkeypatch, capsys):
158
199
  """4 / D8. Sabotage: re-word the refusal and this fails — the client would then hold a
159
200
  second copy of a rule the server already enforces."""
@@ -515,6 +515,8 @@ def test_setup_installs_the_skill_where_the_product_says_skills_live(tmp_path, w
515
515
  dest = repo / ".claude" / "skills" / "graphban-delegation" / "SKILL.md"
516
516
  assert dest.exists()
517
517
  assert dest.read_text().startswith("---\nname: graphban-delegation\n")
518
+ other = repo / ".agents" / "skills" / "graphban-delegation" / "SKILL.md"
519
+ assert other.read_text() == dest.read_text()
518
520
  # THE CALL: skipping `skill()` in run() would leave this dest missing the
519
521
  # watcher paragraph while skill_source() tests still passed.
520
522
  body = dest.read_text()
@@ -533,9 +535,11 @@ def test_setup_installs_every_shipped_skill(tmp_path, wired):
533
535
  assert "graphban-delegation" in slugs
534
536
  assert "watch-wave" in slugs
535
537
  for slug in slugs:
536
- dest = repo / ".claude" / "skills" / slug / "SKILL.md"
537
- assert dest.exists(), slug
538
- assert dest.read_text() == setup_mod.skill_source(slug).read_text()
538
+ body = setup_mod.skill_source(slug).read_text()
539
+ for rel in setup_mod.SKILL_DEST_DIRS:
540
+ dest = repo / rel / slug / "SKILL.md"
541
+ assert dest.exists(), dest
542
+ assert dest.read_text() == body
539
543
  assert sum(1 for l in lines if l.get("name") == "skill" and l.get("status") == "PASS") == len(slugs)
540
544
 
541
545
 
@@ -552,6 +556,9 @@ def test_an_edited_skill_is_left_alone(tmp_path, wired):
552
556
 
553
557
  assert dest.read_text() == "mine\n"
554
558
  assert any("left alone" in l["detail"] for l in lines)
559
+ # The edited copy stays. The directory a different harness reads is still installed.
560
+ agents = repo / ".agents" / "skills" / "graphban-delegation" / "SKILL.md"
561
+ assert agents.read_text() == setup_mod.skill_source("graphban-delegation").read_text()
555
562
  # Editing one skill does not skip the others.
556
563
  other = repo / ".claude" / "skills" / "watch-wave" / "SKILL.md"
557
564
  assert other.exists()
File without changes
File without changes