graphban-cli 0.7.0__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.
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/PKG-INFO +2 -2
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/README.md +1 -1
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/pyproject.toml +22 -1
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/cli.py +25 -1
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/client.py +9 -1
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/setup.py +35 -18
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/skills/graphban-delegation/SKILL.md +10 -1
- graphban_cli-0.8.0/src/gban/skills/graphban-orient/SKILL.md +31 -0
- graphban_cli-0.8.0/src/gban/skills/graphban-prd/SKILL.md +40 -0
- graphban_cli-0.8.0/src/gban/skills/graphban-review/SKILL.md +21 -0
- graphban_cli-0.8.0/src/gban/skills/graphban-scan/SKILL.md +23 -0
- graphban_cli-0.8.0/src/gban/skills/graphban-work/SKILL.md +29 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/skills/watch-wave/SKILL.md +10 -2
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/stackmap.py +111 -17
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/swamp.py +1 -1
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_session.py +41 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_setup.py +10 -3
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_stackmap.py +181 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/.gitignore +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/LICENSE +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/__init__.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/config.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/doctor.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_acts.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_doctor.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.8.0}/tests/test_packaging.py +0 -0
- {graphban_cli-0.7.0 → 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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
]
|
|
@@ -442,6 +442,7 @@ def cmd_stack(args) -> int:
|
|
|
442
442
|
text = stackmap.canonical(record)
|
|
443
443
|
if args.dry_run:
|
|
444
444
|
sys.stdout.write(text)
|
|
445
|
+
print(f"{PROG}: dry run, nothing posted", file=sys.stderr)
|
|
445
446
|
return 0
|
|
446
447
|
if not project:
|
|
447
448
|
print(f"{PROG}: `{PROG} stack map` needs a project to post. Pass --project, set "
|
|
@@ -463,8 +464,31 @@ def cmd_stack(args) -> int:
|
|
|
463
464
|
f"${config.API_KEY_ENV}, or run `{PROG} setup`. The login session is not a "
|
|
464
465
|
f"key and is not sent.", file=sys.stderr)
|
|
465
466
|
return EXIT_REFUSED
|
|
466
|
-
|
|
467
|
+
# The record goes to stdout only after the server took it: a refused post must not
|
|
468
|
+
# leave behind something that reads like a record that was sent (GRPH-1103).
|
|
469
|
+
try:
|
|
470
|
+
receipt = stackmap.post_record(url, key, record)
|
|
471
|
+
except Refused as exc:
|
|
472
|
+
try:
|
|
473
|
+
reasons = json.loads(exc.detail)
|
|
474
|
+
except ValueError:
|
|
475
|
+
reasons = None
|
|
476
|
+
if not isinstance(reasons, list):
|
|
477
|
+
raise
|
|
478
|
+
print(f"{PROG}: the server refused the app map (HTTP {exc.status}):", file=sys.stderr)
|
|
479
|
+
for reason in reasons:
|
|
480
|
+
if isinstance(reason, dict):
|
|
481
|
+
print(f" {reason.get('path', '?')}: {reason.get('reason', '')}",
|
|
482
|
+
file=sys.stderr)
|
|
483
|
+
else:
|
|
484
|
+
print(f" {reason}", file=sys.stderr)
|
|
485
|
+
return EXIT_REFUSED
|
|
467
486
|
sys.stdout.write(text)
|
|
487
|
+
receipt = receipt if isinstance(receipt, dict) else {}
|
|
488
|
+
sha = str(receipt.get("mapped_sha") or record.get("mapped_sha") or "")[:7]
|
|
489
|
+
said = [f"{k} {receipt[k]}" for k in ("mapped_at", "posted_by") if receipt.get(k)]
|
|
490
|
+
print(f"{PROG}: posted {receipt.get('project_id') or project} app map at {sha} to {url}"
|
|
491
|
+
+ (f" ({', '.join(said)})" if said else ""), file=sys.stderr)
|
|
468
492
|
return 0
|
|
469
493
|
|
|
470
494
|
|
|
@@ -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
|
-
|
|
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:
|
|
@@ -115,6 +119,10 @@ class Client:
|
|
|
115
119
|
detail = payload.get("detail") or payload.get("message") or f"HTTP {status}"
|
|
116
120
|
if isinstance(detail, dict):
|
|
117
121
|
detail, hint = (detail.get("message") or str(detail)), detail.get("hint", "")
|
|
122
|
+
elif isinstance(detail, list):
|
|
123
|
+
# A list of reasons (e.g. `{path, reason}` from a refused app map) stays JSON,
|
|
124
|
+
# so a caller can read it back rather than parse a Python repr.
|
|
125
|
+
detail, hint = json.dumps(detail), payload.get("hint", "")
|
|
118
126
|
else:
|
|
119
127
|
hint = payload.get("hint", "")
|
|
120
128
|
raise Refused(status, str(detail), str(hint))
|
|
@@ -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
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -853,6 +854,13 @@ class _Builder:
|
|
|
853
854
|
self.by_dir: dict[str, str] = {}
|
|
854
855
|
self.kind_of: dict[str, str] = {}
|
|
855
856
|
self.warnings: list[str] = []
|
|
857
|
+
# Which compose services name an env variable, and which read a template
|
|
858
|
+
# through `env_file:`. Every service counts, not only the ones that build a
|
|
859
|
+
# component: a repo-root template is attributed from these.
|
|
860
|
+
self.env_users: dict[str, set[str]] = {}
|
|
861
|
+
self.env_file_users: dict[str, set[str]] = {}
|
|
862
|
+
# Names each `.env.example` declares, keyed by its directory.
|
|
863
|
+
self.template_names: dict[str, set[str]] = {}
|
|
856
864
|
|
|
857
865
|
def _ids(self) -> set[str]:
|
|
858
866
|
return set(self.components) | set(self.infra) | set(self.external) | set(self.unresolved)
|
|
@@ -1109,6 +1117,32 @@ def _extends(service: Marked) -> tuple[str, str, int] | None:
|
|
|
1109
1117
|
return None
|
|
1110
1118
|
|
|
1111
1119
|
|
|
1120
|
+
def _env_files(service: Marked, compose: Path, repo: Path) -> list[str]:
|
|
1121
|
+
"""Repo-relative paths a service's `env_file:` names."""
|
|
1122
|
+
if not isinstance(service.value, dict):
|
|
1123
|
+
return []
|
|
1124
|
+
node = service.value.get("env_file")
|
|
1125
|
+
if not isinstance(node, Marked):
|
|
1126
|
+
return []
|
|
1127
|
+
items = node.value if isinstance(node.value, list) else [node]
|
|
1128
|
+
out: list[str] = []
|
|
1129
|
+
for item in items:
|
|
1130
|
+
if not isinstance(item, Marked):
|
|
1131
|
+
continue
|
|
1132
|
+
raw = item.value
|
|
1133
|
+
if isinstance(raw, dict):
|
|
1134
|
+
# The long form: `- path: ./x.env`.
|
|
1135
|
+
raw = raw.get("path").value if isinstance(raw.get("path"), Marked) else None
|
|
1136
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
1137
|
+
continue
|
|
1138
|
+
try:
|
|
1139
|
+
target = (compose.parent / raw.strip().strip("'\"")).resolve()
|
|
1140
|
+
out.append(target.relative_to(repo.resolve()).as_posix())
|
|
1141
|
+
except ValueError:
|
|
1142
|
+
continue
|
|
1143
|
+
return out
|
|
1144
|
+
|
|
1145
|
+
|
|
1112
1146
|
def _walk_compose(builder: _Builder, path: Path) -> None:
|
|
1113
1147
|
text = _read_text(path)
|
|
1114
1148
|
if text is None:
|
|
@@ -1185,7 +1219,13 @@ def _walk_compose(builder: _Builder, path: Path) -> None:
|
|
|
1185
1219
|
builder.add_edge(src, target, protocol, "depends_on", prov)
|
|
1186
1220
|
env_node = service.value.get("environment")
|
|
1187
1221
|
for ename, raw, line in _as_env(env_node if isinstance(env_node, Marked) else None):
|
|
1222
|
+
for mentioned in [ename] + _INTERP.findall(raw):
|
|
1223
|
+
mentioned = re.split(r"[:?+-]", mentioned, maxsplit=1)[0].strip()
|
|
1224
|
+
if mentioned:
|
|
1225
|
+
builder.env_users.setdefault(mentioned, set()).add(src)
|
|
1188
1226
|
builder.apply_env_name(ename, raw, line, file, [src], "compose.env")
|
|
1227
|
+
for template in _env_files(service, path, builder.repo):
|
|
1228
|
+
builder.env_file_users.setdefault(template, set()).add(src)
|
|
1189
1229
|
|
|
1190
1230
|
|
|
1191
1231
|
def _walk_dockerfile(builder: _Builder, path: Path) -> None:
|
|
@@ -1247,6 +1287,61 @@ def _walk_manifest(builder: _Builder, path: Path) -> None:
|
|
|
1247
1287
|
str(row.get("label") or ""), prov)
|
|
1248
1288
|
|
|
1249
1289
|
|
|
1290
|
+
def _template_entries(text: str) -> list[tuple[str, str, int]]:
|
|
1291
|
+
"""`(name, raw_value, line)` for each assignment in an env template."""
|
|
1292
|
+
entries: list[tuple[str, str, int]] = []
|
|
1293
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
1294
|
+
line = raw.strip()
|
|
1295
|
+
if not line or line.startswith("#"):
|
|
1296
|
+
continue
|
|
1297
|
+
if line.startswith("export "):
|
|
1298
|
+
line = line[len("export "):].strip()
|
|
1299
|
+
if "=" not in line:
|
|
1300
|
+
continue
|
|
1301
|
+
name, _, raw_value = line.partition("=")
|
|
1302
|
+
name = name.strip()
|
|
1303
|
+
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'):
|
|
1304
|
+
raw_value = raw_value[1:-1]
|
|
1305
|
+
entries.append((name, raw_value, lineno))
|
|
1306
|
+
return entries
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _note_template(builder: _Builder, path: Path) -> None:
|
|
1310
|
+
text = _read_text(path)
|
|
1311
|
+
if text is None:
|
|
1312
|
+
return
|
|
1313
|
+
names = {name for name, _raw, _line in _template_entries(text)}
|
|
1314
|
+
builder.template_names.setdefault(_rel(path.parent, builder.repo), set()).update(names)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _root_consumers(builder: _Builder, file: str,
|
|
1318
|
+
entries: list[tuple[str, str, int]]) -> dict[str, list[str]] | None:
|
|
1319
|
+
"""Per name, the compose services a repo-root template's variable belongs to.
|
|
1320
|
+
|
|
1321
|
+
Any compose service counts, a plain `image:` one too. None only when no
|
|
1322
|
+
service names any of the template's variables or reads it: the caller then
|
|
1323
|
+
falls back to every component and says so. A name compose does not mention goes to
|
|
1324
|
+
the components whose own directory template declares it, else to the
|
|
1325
|
+
component at the repo root, else to nobody. The node is still drawn.
|
|
1326
|
+
"""
|
|
1327
|
+
readers = builder.env_file_users.get(file, set())
|
|
1328
|
+
names = [name for name, _raw, _line in entries if _interesting_env(name)]
|
|
1329
|
+
if not readers and not any(name in builder.env_users for name in names):
|
|
1330
|
+
return None
|
|
1331
|
+
root = builder.by_dir.get("")
|
|
1332
|
+
out: dict[str, list[str]] = {}
|
|
1333
|
+
for name in names:
|
|
1334
|
+
users = readers | builder.env_users.get(name, set())
|
|
1335
|
+
if not users:
|
|
1336
|
+
users = {builder.by_dir[folder]
|
|
1337
|
+
for folder, declared in builder.template_names.items()
|
|
1338
|
+
if folder and name in declared and folder in builder.by_dir}
|
|
1339
|
+
if not users and root:
|
|
1340
|
+
users = {root}
|
|
1341
|
+
out[name] = sorted(users)
|
|
1342
|
+
return out
|
|
1343
|
+
|
|
1344
|
+
|
|
1250
1345
|
def _walk_env_template(builder: _Builder, path: Path) -> None:
|
|
1251
1346
|
text = _read_text(path)
|
|
1252
1347
|
if text is None:
|
|
@@ -1254,8 +1349,15 @@ def _walk_env_template(builder: _Builder, path: Path) -> None:
|
|
|
1254
1349
|
return
|
|
1255
1350
|
file = _rel(path, builder.repo)
|
|
1256
1351
|
directory = _rel(path.parent, builder.repo)
|
|
1352
|
+
entries = _template_entries(text)
|
|
1353
|
+
consumers: dict[str, list[str]] | None = None
|
|
1257
1354
|
if directory == "":
|
|
1355
|
+
consumers = _root_consumers(builder, file, entries)
|
|
1258
1356
|
sources = sorted(builder.components)
|
|
1357
|
+
if consumers is None and len(sources) > 1:
|
|
1358
|
+
builder.warnings.append(
|
|
1359
|
+
f"{file}: no compose service names its variables or reads it; "
|
|
1360
|
+
f"its names are attributed to every component")
|
|
1259
1361
|
else:
|
|
1260
1362
|
owner = builder.owner(directory)
|
|
1261
1363
|
sources = [owner] if owner else []
|
|
@@ -1266,19 +1368,9 @@ def _walk_env_template(builder: _Builder, path: Path) -> None:
|
|
|
1266
1368
|
# the names belong to, so the edges have somewhere to start.
|
|
1267
1369
|
sources = [builder.add_component(
|
|
1268
1370
|
".", "", ".", _provenance(file, "env.template", 1))]
|
|
1269
|
-
for
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
continue
|
|
1273
|
-
if line.startswith("export "):
|
|
1274
|
-
line = line[len("export "):].strip()
|
|
1275
|
-
if "=" not in line:
|
|
1276
|
-
continue
|
|
1277
|
-
name, _, raw_value = line.partition("=")
|
|
1278
|
-
name = name.strip()
|
|
1279
|
-
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'):
|
|
1280
|
-
raw_value = raw_value[1:-1]
|
|
1281
|
-
builder.apply_env_name(name, raw_value, lineno, file, sources, "env.template")
|
|
1371
|
+
for name, raw_value, lineno in entries:
|
|
1372
|
+
named = consumers.get(name, sources) if consumers is not None else sources
|
|
1373
|
+
builder.apply_env_name(name, raw_value, lineno, file, named, "env.template")
|
|
1282
1374
|
|
|
1283
1375
|
|
|
1284
1376
|
def _sha(repo: Path) -> str:
|
|
@@ -1333,9 +1425,11 @@ def build_record(start: Path, *, project_id: str = "") -> tuple[dict, list[str]]
|
|
|
1333
1425
|
for path in files:
|
|
1334
1426
|
if _is_manifest(path):
|
|
1335
1427
|
_walk_manifest(builder, path)
|
|
1336
|
-
for path in files
|
|
1337
|
-
|
|
1338
|
-
|
|
1428
|
+
templates = [path for path in files if path.name == ".env.example"]
|
|
1429
|
+
for path in templates:
|
|
1430
|
+
_note_template(builder, path)
|
|
1431
|
+
for path in templates:
|
|
1432
|
+
_walk_env_template(builder, path)
|
|
1339
1433
|
if not (builder.components or builder.infra or builder.external or builder.unresolved):
|
|
1340
1434
|
raise MapError("nothing to map: no compose service, Dockerfile or known dependency")
|
|
1341
1435
|
return builder.finish(sha), builder.warnings
|
|
@@ -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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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()
|
|
@@ -537,3 +537,184 @@ def test_ignored_and_untracked_files_are_not_mapped(tmp_path):
|
|
|
537
537
|
(repo / stray / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8")
|
|
538
538
|
record, _ = stackmap.build_record(repo)
|
|
539
539
|
assert [n["dir"] for n in record["components"]] == ["app"]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _root_template_repo(tmp_path, compose: str | None) -> Path:
|
|
543
|
+
repo = tmp_path / "rooted"
|
|
544
|
+
repo.mkdir()
|
|
545
|
+
for name in ("api", "web"):
|
|
546
|
+
(repo / name).mkdir()
|
|
547
|
+
(repo / name / "requirements.txt").write_text("stripe==8.0\n", encoding="utf-8")
|
|
548
|
+
if compose is not None:
|
|
549
|
+
(repo / "compose.yml").write_text(compose, encoding="utf-8")
|
|
550
|
+
(repo / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
|
|
551
|
+
_commit(repo)
|
|
552
|
+
return repo
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_a_root_template_name_goes_to_the_service_that_declares_it(tmp_path):
|
|
556
|
+
"""GRPH-1104: a root .env.example drew web -> openai when only api reads it."""
|
|
557
|
+
repo = _root_template_repo(tmp_path,
|
|
558
|
+
"services:\n"
|
|
559
|
+
" api:\n"
|
|
560
|
+
" build: ./api\n"
|
|
561
|
+
" environment:\n"
|
|
562
|
+
" - OPENAI_API_KEY\n"
|
|
563
|
+
" web:\n"
|
|
564
|
+
" build: ./web\n")
|
|
565
|
+
record, warnings = stackmap.build_record(repo)
|
|
566
|
+
to_openai = [(e["from"], e["to"]) for e in record["edges"] if e["to"] == "openai"]
|
|
567
|
+
assert to_openai == [("api", "openai")]
|
|
568
|
+
assert not [e for e in record["edges"] if e["from"] == "web" and e["to"] != "stripe"]
|
|
569
|
+
assert warnings == []
|
|
570
|
+
assert canonical(record) == canonical(stackmap.build_record(repo)[0])
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def test_a_root_template_with_no_compose_goes_to_every_component_and_says_so(tmp_path):
|
|
574
|
+
repo = _root_template_repo(tmp_path, None)
|
|
575
|
+
record, warnings = stackmap.build_record(repo)
|
|
576
|
+
assert sorted(n["id"] for n in record["components"]) == ["api", "web"]
|
|
577
|
+
assert sorted(e["from"] for e in record["edges"] if e["to"] == "openai") == ["api", "web"]
|
|
578
|
+
assert [w for w in warnings if ".env.example" in w]
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_a_root_template_read_through_env_file_goes_to_its_reader(tmp_path):
|
|
582
|
+
repo = _root_template_repo(tmp_path,
|
|
583
|
+
"services:\n"
|
|
584
|
+
" api:\n"
|
|
585
|
+
" build: ./api\n"
|
|
586
|
+
" web:\n"
|
|
587
|
+
" build: ./web\n"
|
|
588
|
+
" env_file: .env.example\n")
|
|
589
|
+
record, warnings = stackmap.build_record(repo)
|
|
590
|
+
assert [e["from"] for e in record["edges"] if e["to"] == "openai"] == ["web"]
|
|
591
|
+
assert warnings == []
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@pytest.mark.parametrize("worker_env", [
|
|
595
|
+
" environment:\n - OPENAI_API_KEY\n",
|
|
596
|
+
" env_file: .env.example\n",
|
|
597
|
+
])
|
|
598
|
+
def test_a_root_template_named_only_by_an_image_service_is_not_every_component(
|
|
599
|
+
tmp_path, worker_env):
|
|
600
|
+
"""GRPH-1104 bounce: a service that builds nothing still counts as compose naming it."""
|
|
601
|
+
repo = tmp_path / "imaged"
|
|
602
|
+
repo.mkdir()
|
|
603
|
+
for name in ("api", "web"):
|
|
604
|
+
(repo / name).mkdir()
|
|
605
|
+
(repo / name / "requirements.txt").write_text("stripe==8.0\n", encoding="utf-8")
|
|
606
|
+
(repo / "compose.yml").write_text(
|
|
607
|
+
"services:\n"
|
|
608
|
+
" api:\n"
|
|
609
|
+
" build: ./api\n"
|
|
610
|
+
" web:\n"
|
|
611
|
+
" build: ./web\n"
|
|
612
|
+
" worker:\n"
|
|
613
|
+
" image: alpine\n" + worker_env, encoding="utf-8")
|
|
614
|
+
(repo / ".env.example").write_text("OPENAI_API_KEY=\nANTHROPIC_API_KEY=\n",
|
|
615
|
+
encoding="utf-8")
|
|
616
|
+
_commit(repo)
|
|
617
|
+
record, warnings = stackmap.build_record(repo)
|
|
618
|
+
assert not [e for e in record["edges"]
|
|
619
|
+
if e["from"] in ("api", "web") and e["to"] in ("openai", "anthropic")]
|
|
620
|
+
assert [e["from"] for e in record["edges"] if e["to"] == "openai"] == ["worker"]
|
|
621
|
+
assert not [w for w in warnings if ".env.example" in w]
|
|
622
|
+
assert {"openai", "anthropic"} <= {n["id"] for n in record["external"]}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_this_repo_root_template_draws_no_web_edge_to_a_model_provider():
|
|
626
|
+
record, _ = stackmap.build_record(ROOT)
|
|
627
|
+
providers = {"anthropic", "openai", stackmap.LOCAL_NODE}
|
|
628
|
+
assert not [e for e in record["edges"] if e["from"] == "web" and e["to"] in providers]
|
|
629
|
+
assert _edge(record, "web", "api", "depends_on")
|
|
630
|
+
ids = {n["id"] for n in record["external"] + record["infrastructure"]}
|
|
631
|
+
assert providers <= ids
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---- what a post says it did (GRPH-1103) ----------------------------------------------------
|
|
635
|
+
# A real HTTP server on loopback, so the confirmation and the refusal go through the same
|
|
636
|
+
# transport and error parsing a person's post does.
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@pytest.fixture()
|
|
640
|
+
def fake_server():
|
|
641
|
+
import threading
|
|
642
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
643
|
+
|
|
644
|
+
state = {"requests": [], "status": 200, "reply": {}}
|
|
645
|
+
|
|
646
|
+
class Handler(BaseHTTPRequestHandler):
|
|
647
|
+
def do_POST(self): # noqa: N802
|
|
648
|
+
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
649
|
+
state["requests"].append((self.path, self.headers.get("X-API-Key"), json.loads(raw)))
|
|
650
|
+
reply = state["reply"](json.loads(raw)) if callable(state["reply"]) else state["reply"]
|
|
651
|
+
data = json.dumps(reply).encode()
|
|
652
|
+
self.send_response(state["status"])
|
|
653
|
+
self.send_header("Content-Type", "application/json")
|
|
654
|
+
self.send_header("Content-Length", str(len(data)))
|
|
655
|
+
self.end_headers()
|
|
656
|
+
self.wfile.write(data)
|
|
657
|
+
|
|
658
|
+
def log_message(self, *_args):
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
662
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
663
|
+
thread.start()
|
|
664
|
+
state["url"] = f"http://127.0.0.1:{server.server_address[1]}"
|
|
665
|
+
try:
|
|
666
|
+
yield state
|
|
667
|
+
finally:
|
|
668
|
+
server.shutdown()
|
|
669
|
+
server.server_close()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _mapped_app(isolated, monkeypatch) -> tuple[Path, str]:
|
|
673
|
+
repo = isolated / "app"
|
|
674
|
+
repo.mkdir()
|
|
675
|
+
_write_app(repo)
|
|
676
|
+
sha = _commit(repo)
|
|
677
|
+
monkeypatch.setenv("GBFLEET_API_KEY", "gb_sk_seat")
|
|
678
|
+
return repo, sha
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def test_a_real_post_says_it_posted_and_where(isolated, monkeypatch, capsys, fake_server):
|
|
682
|
+
repo, sha = _mapped_app(isolated, monkeypatch)
|
|
683
|
+
fake_server["reply"] = lambda body: {**body, "mapped_at": "2026-10-23T10:00:00Z",
|
|
684
|
+
"posted_by": "seat-1"}
|
|
685
|
+
assert main(["--server", fake_server["url"], "--project", "demo",
|
|
686
|
+
"stack", "map", "--repo", str(repo)]) == 0
|
|
687
|
+
assert len(fake_server["requests"]) == 1
|
|
688
|
+
captured = capsys.readouterr()
|
|
689
|
+
assert captured.out == canonical(fake_server["requests"][0][2])
|
|
690
|
+
assert (f"gban: posted demo app map at {sha[:7]} to {fake_server['url']}"
|
|
691
|
+
in captured.err)
|
|
692
|
+
assert "posted_by seat-1" in captured.err
|
|
693
|
+
assert "dry run" not in captured.err
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def test_dry_run_says_nothing_was_posted(isolated, monkeypatch, capsys, fake_server):
|
|
697
|
+
repo, _ = _mapped_app(isolated, monkeypatch)
|
|
698
|
+
assert main(["--server", fake_server["url"], "--project", "demo",
|
|
699
|
+
"stack", "map", "--dry-run", "--repo", str(repo)]) == 0
|
|
700
|
+
captured = capsys.readouterr()
|
|
701
|
+
assert fake_server["requests"] == []
|
|
702
|
+
assert "gban: dry run, nothing posted" in captured.err
|
|
703
|
+
assert "posted demo" not in captured.err
|
|
704
|
+
assert json.loads(captured.out)["project_id"] == "demo"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def test_a_refused_post_prints_the_reasons_and_not_the_record(isolated, monkeypatch, capsys,
|
|
708
|
+
fake_server):
|
|
709
|
+
repo, _ = _mapped_app(isolated, monkeypatch)
|
|
710
|
+
fake_server["status"] = 422
|
|
711
|
+
fake_server["reply"] = {"detail": [{"path": "edges[2].to", "reason": "unresolved node"},
|
|
712
|
+
{"path": "mapped_sha", "reason": "unknown is not a commit"}]}
|
|
713
|
+
assert main(["--server", fake_server["url"], "--project", "demo",
|
|
714
|
+
"stack", "map", "--repo", str(repo)]) == EXIT_REFUSED
|
|
715
|
+
assert len(fake_server["requests"]) == 1
|
|
716
|
+
captured = capsys.readouterr()
|
|
717
|
+
assert captured.out == ""
|
|
718
|
+
assert "edges[2].to: unresolved node" in captured.err
|
|
719
|
+
assert "mapped_sha: unknown is not a commit" in captured.err
|
|
720
|
+
assert "posted demo" not in captured.err
|
|
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
|