@ikon85/agent-workflow-kit 0.27.0 → 0.28.0
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.
- package/.agents/skills/ask-matt/SKILL.md +2 -2
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +2 -2
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -2
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +2 -2
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -2
- package/README.md +51 -7
- package/agent-workflow-kit.package.json +50 -34
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/package.json +1 -1
- package/scripts/board-sync.py +184 -5
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/release-parity.mjs +34 -0
- package/scripts/release-parity.test.mjs +37 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_retro_wrapup_contract.py +111 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +49 -10
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +39 -11
- package/src/lib/bundle.mjs +4 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- package/src/lib/updateReconcile.mjs +45 -6
package/scripts/board-sync.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""board-sync.py — single GitHub Projects-v2 board-sync / sub-issue helper.
|
|
3
3
|
|
|
4
|
-
Encapsulates the
|
|
4
|
+
Encapsulates the board mechanics the planning skills used to inline as bare
|
|
5
5
|
`gh` snippets (board-to-waves, to-prd, to-issues):
|
|
6
6
|
|
|
7
7
|
1. GraphQL-Link — native parent↔child sub-issue link (`link`)
|
|
@@ -9,6 +9,10 @@ Encapsulates the five board mechanics the planning skills used to inline as bare
|
|
|
9
9
|
3. preview-header — `GraphQL-Features: sub_issues` on every sub-issue call
|
|
10
10
|
4. Wave-Stempel — stamp the Wave (number) field (`--wave` on `create`/`add`)
|
|
11
11
|
5. board-sync — add an issue to the board + set Status/Wave/Cluster/Path fields
|
|
12
|
+
6. next-wave — rate-efficient title search with a bounded full-scan fallback
|
|
13
|
+
7. promotion guard — reject a Wave number already held by another anchor
|
|
14
|
+
8. item-of — targeted item/field lookup without scanning the whole board
|
|
15
|
+
9. archive-done — dry-run-first, bounded-batch archival of active Done items
|
|
12
16
|
|
|
13
17
|
Board-specific values (field IDs, status names, labels) are NOT inlined here —
|
|
14
18
|
board_config reads them from the `board-sync:profile` block in
|
|
@@ -95,6 +99,8 @@ def _status_roles_or_hint(context: str) -> dict:
|
|
|
95
99
|
return STATUS_ROLES
|
|
96
100
|
PROJECT_ITEM_LIST_LIMIT = 2000
|
|
97
101
|
GH_TIMEOUT_SECONDS = 15 # a hanging gh prompt must not block a session indefinitely
|
|
102
|
+
PROJECT_ITEM_LIST_TIMEOUT_SECONDS = 60 # full-board reads are slower, but still bounded
|
|
103
|
+
ARCHIVE_DONE_BATCH_SIZE = 30
|
|
98
104
|
|
|
99
105
|
# Sub-issues GraphQL API is behind a preview feature gate per account.
|
|
100
106
|
SUB_ISSUES_HEADER = "GraphQL-Features: sub_issues"
|
|
@@ -127,11 +133,13 @@ class GhError(RuntimeError):
|
|
|
127
133
|
# --- subprocess seam (the only thing tests monkeypatch) ----------------------
|
|
128
134
|
def _gh(args: list[str]) -> str:
|
|
129
135
|
"""Run `gh <args>`, return stdout. Raise GhError on non-zero exit or timeout."""
|
|
136
|
+
timeout = (PROJECT_ITEM_LIST_TIMEOUT_SECONDS
|
|
137
|
+
if args[:2] == ["project", "item-list"] else GH_TIMEOUT_SECONDS)
|
|
130
138
|
try:
|
|
131
139
|
result = subprocess.run(["gh", *args], capture_output=True, text=True,
|
|
132
|
-
timeout=
|
|
140
|
+
timeout=timeout)
|
|
133
141
|
except subprocess.TimeoutExpired as exc:
|
|
134
|
-
raise GhError(f"gh {' '.join(args)} timed out after {
|
|
142
|
+
raise GhError(f"gh {' '.join(args)} timed out after {timeout}s") from exc
|
|
135
143
|
if result.returncode != 0:
|
|
136
144
|
# `gh api graphql` exits non-zero when the response body carries GraphQL
|
|
137
145
|
# `errors` even alongside a partial `data` (stamp-batch's per-alias
|
|
@@ -422,6 +430,10 @@ WAVE_TITLE_PREFIX = wave_title_prefix(_CFG)
|
|
|
422
430
|
_WAVE_PREFIX_RE = re.compile(
|
|
423
431
|
rf"^\s*{re.escape(WAVE_TITLE_PREFIX)}\s+\d+\s*[—–-]\s*", re.IGNORECASE
|
|
424
432
|
)
|
|
433
|
+
_WAVE_NUMBER_RE = re.compile(rf"^{re.escape(WAVE_TITLE_PREFIX)}\s+(\d+)\b")
|
|
434
|
+
_ANCHOR_TITLE_RE = re.compile(
|
|
435
|
+
rf"^{re.escape(WAVE_TITLE_PREFIX)}\s+(\d+)\s+—\s+"
|
|
436
|
+
)
|
|
425
437
|
# Leading conventional-commit token (`fix:`, `feat(ui):`, `chore!:`) — anchors
|
|
426
438
|
# don't carry these, so strip it when present. Matches only a lowercase token,
|
|
427
439
|
# so prose titles like "Supabase-Residuen entfernen: …" are left intact.
|
|
@@ -443,6 +455,26 @@ def wave_title(current: str, wave: int) -> str:
|
|
|
443
455
|
return f"{WAVE_TITLE_PREFIX} {wave} — {thema}"
|
|
444
456
|
|
|
445
457
|
|
|
458
|
+
def compute_next_wave_from_search(payload: dict) -> Optional[int]:
|
|
459
|
+
"""Return max wave-title number + 1, or None so callers can full-scan."""
|
|
460
|
+
numbers = []
|
|
461
|
+
for item in payload.get("items", []):
|
|
462
|
+
match = _WAVE_NUMBER_RE.match(item.get("title") or "")
|
|
463
|
+
if match:
|
|
464
|
+
numbers.append(int(match.group(1)))
|
|
465
|
+
return max(numbers) + 1 if numbers else None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def wave_collision_guard(payload: dict, wave: int, issue: int) -> Optional[str]:
|
|
469
|
+
"""Reject a wave number carried by another anchor; allow replay and leaves."""
|
|
470
|
+
for item in payload.get("items", []):
|
|
471
|
+
match = _ANCHOR_TITLE_RE.match(item.get("title") or "")
|
|
472
|
+
if match and int(match.group(1)) == wave and item.get("number") != issue:
|
|
473
|
+
return (f"Wave {wave} is already taken by #{item.get('number')} "
|
|
474
|
+
f"({item.get('title')!r}); re-read `next-wave` and retry")
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
446
478
|
def title_edit_args(issue: int, title: str) -> list[str]:
|
|
447
479
|
"""The `gh issue edit` argv that sets an issue's title."""
|
|
448
480
|
return ["issue", "edit", str(issue), "--repo", REPO, "--title", title]
|
|
@@ -511,6 +543,43 @@ def _field_value(issue: int, field_id: str) -> Optional[dict]:
|
|
|
511
543
|
return extract_field_value(data, PROJECT_NODE_ID, field_id)
|
|
512
544
|
|
|
513
545
|
|
|
546
|
+
def extract_item_overview(data: dict, project_node_id: str, field_ids: dict) -> Optional[dict]:
|
|
547
|
+
"""Extract one issue's item id and configured field values for this project."""
|
|
548
|
+
issue = (data.get("data") or {}).get("repository", {}).get("issue") or {}
|
|
549
|
+
for node in (issue.get("projectItems") or {}).get("nodes") or []:
|
|
550
|
+
if (node.get("project") or {}).get("id") != project_node_id:
|
|
551
|
+
continue
|
|
552
|
+
by_field = {(value.get("field") or {}).get("id"): value
|
|
553
|
+
for value in (node.get("fieldValues") or {}).get("nodes") or []}
|
|
554
|
+
overview = {"itemId": node.get("id")}
|
|
555
|
+
for name, field_id in field_ids.items():
|
|
556
|
+
value = by_field.get(field_id)
|
|
557
|
+
if value is None:
|
|
558
|
+
overview[name] = None
|
|
559
|
+
elif "number" in value:
|
|
560
|
+
overview[name] = value["number"]
|
|
561
|
+
elif "name" in value:
|
|
562
|
+
overview[name] = value["name"]
|
|
563
|
+
else:
|
|
564
|
+
overview[name] = value.get("text")
|
|
565
|
+
return overview
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def cmd_item_of(args) -> int:
|
|
570
|
+
data = _gh_json(["api", "graphql", "-f", f"query={FIELD_VALUE_QUERY}",
|
|
571
|
+
"-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
|
|
572
|
+
"-F", f"num={args.issue}"])
|
|
573
|
+
overview = extract_item_overview(data, PROJECT_NODE_ID, {
|
|
574
|
+
"wave": WAVE_FIELD_ID, "status": STATUS_FIELD_ID, "cluster": CLUSTER_FIELD_ID,
|
|
575
|
+
})
|
|
576
|
+
if overview is None:
|
|
577
|
+
print("NOT-ON-BOARD")
|
|
578
|
+
return 1
|
|
579
|
+
print(json.dumps(overview, ensure_ascii=False))
|
|
580
|
+
return 0
|
|
581
|
+
|
|
582
|
+
|
|
514
583
|
def _write_issue_body(issue: int, new_body: str) -> None:
|
|
515
584
|
"""Write an issue body via a temp file + `gh issue edit --body-file` — the
|
|
516
585
|
one shared write mechanic for every body-rewriting command in this file."""
|
|
@@ -534,13 +603,101 @@ def _add_and_stamp(url, wave, status, cluster, spec_path, plan_path, dry_run) ->
|
|
|
534
603
|
|
|
535
604
|
|
|
536
605
|
# --- commands ----------------------------------------------------------------
|
|
537
|
-
def cmd_next_wave(
|
|
606
|
+
def cmd_next_wave(args) -> int:
|
|
607
|
+
if not getattr(args, "scan", False):
|
|
608
|
+
payload = _gh_json(["api", "-X", "GET", "search/issues",
|
|
609
|
+
"-f", f'q=repo:{REPO} is:issue in:title "{WAVE_TITLE_PREFIX}"',
|
|
610
|
+
"-f", "per_page=100", "-f", "sort=created", "-f", "order=desc"])
|
|
611
|
+
next_wave = compute_next_wave_from_search(payload)
|
|
612
|
+
if next_wave is not None:
|
|
613
|
+
print(next_wave)
|
|
614
|
+
return 0
|
|
615
|
+
print(f"hint: title search found no '{WAVE_TITLE_PREFIX} <N>' issue; "
|
|
616
|
+
"falling back to the full board scan", file=sys.stderr)
|
|
538
617
|
items = _gh_json(["project", "item-list", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
|
|
539
618
|
"--limit", str(PROJECT_ITEM_LIST_LIMIT), "--format", "json"]).get("items", [])
|
|
540
619
|
print(compute_next_wave(items))
|
|
541
620
|
return 0
|
|
542
621
|
|
|
543
622
|
|
|
623
|
+
def extract_done_items(items: list[dict], done_status: str) -> list[dict]:
|
|
624
|
+
return [item for item in items
|
|
625
|
+
if item.get("id") and item.get("status") == done_status
|
|
626
|
+
and not item.get("archived", False) and not item.get("isArchived", False)]
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_archive_mutation(item_ids: list[str]) -> str:
|
|
630
|
+
calls = []
|
|
631
|
+
for index, item_id in enumerate(item_ids):
|
|
632
|
+
calls.append(
|
|
633
|
+
f"a{index}: archiveProjectV2Item(input: "
|
|
634
|
+
f"{{projectId: {json.dumps(PROJECT_NODE_ID)}, itemId: {json.dumps(item_id)}}}) "
|
|
635
|
+
"{ item { id } }"
|
|
636
|
+
)
|
|
637
|
+
return "mutation { " + " ".join(calls) + " }"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def parse_archive_response(response: dict, item_ids: list[str]) -> tuple[list[str], list[tuple[str, str]]]:
|
|
641
|
+
data = response.get("data") or {}
|
|
642
|
+
errors = {error["path"][0]: error.get("message", "unknown GraphQL error")
|
|
643
|
+
for error in response.get("errors") or [] if error.get("path")}
|
|
644
|
+
global_errors = [error.get("message", "unknown GraphQL error")
|
|
645
|
+
for error in response.get("errors") or [] if not error.get("path")]
|
|
646
|
+
fallback = "; ".join(global_errors) or "no successful response"
|
|
647
|
+
succeeded, failed = [], []
|
|
648
|
+
for index, item_id in enumerate(item_ids):
|
|
649
|
+
alias = f"a{index}"
|
|
650
|
+
if data.get(alias) and data[alias].get("item"):
|
|
651
|
+
succeeded.append(item_id)
|
|
652
|
+
else:
|
|
653
|
+
failed.append((item_id, errors.get(alias, fallback)))
|
|
654
|
+
return succeeded, failed
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def cmd_archive_done(args) -> int:
|
|
658
|
+
done_status = resolve_status_role("done", _status_roles_or_hint("archive-done"))
|
|
659
|
+
payload = _gh_json(["project", "item-list", str(PROJECT_NUMBER),
|
|
660
|
+
"--owner", PROJECT_OWNER, "--limit", str(PROJECT_ITEM_LIST_LIMIT),
|
|
661
|
+
"--format", "json", "--jq",
|
|
662
|
+
"{items: [.items[] | {id, status, archived, isArchived}], "
|
|
663
|
+
"totalCount: .totalCount}"])
|
|
664
|
+
items = payload.get("items", [])
|
|
665
|
+
total_count = payload.get("totalCount", len(items))
|
|
666
|
+
if total_count > len(items):
|
|
667
|
+
raise ValueError(f"project item-list returned {len(items)} of {total_count} items; "
|
|
668
|
+
"refusing a partial archive")
|
|
669
|
+
done_items = extract_done_items(items, done_status)
|
|
670
|
+
print(f"archive-done: {len(done_items)} of {total_count} active items have status {done_status}")
|
|
671
|
+
if not done_items:
|
|
672
|
+
print("archive-done: nothing to archive (idempotent)")
|
|
673
|
+
return 0
|
|
674
|
+
chunks = [done_items[start:start + ARCHIVE_DONE_BATCH_SIZE]
|
|
675
|
+
for start in range(0, len(done_items), ARCHIVE_DONE_BATCH_SIZE)]
|
|
676
|
+
if not args.apply:
|
|
677
|
+
for chunk in chunks:
|
|
678
|
+
_print_dry(["api", "graphql", "-f",
|
|
679
|
+
f"query={build_archive_mutation([item['id'] for item in chunk])}"])
|
|
680
|
+
print("archive-done: dry-run only; pass --apply to archive")
|
|
681
|
+
return 0
|
|
682
|
+
succeeded, failed = [], []
|
|
683
|
+
for chunk in chunks:
|
|
684
|
+
item_ids = [item["id"] for item in chunk]
|
|
685
|
+
query = build_archive_mutation(item_ids)
|
|
686
|
+
try:
|
|
687
|
+
output = _gh(["api", "graphql", "-f", f"query={query}"])
|
|
688
|
+
except GhError as exc:
|
|
689
|
+
output = getattr(exc, "stdout", "") or ""
|
|
690
|
+
if not output:
|
|
691
|
+
raise
|
|
692
|
+
batch_succeeded, batch_failed = parse_archive_response(json.loads(output), item_ids)
|
|
693
|
+
succeeded += batch_succeeded
|
|
694
|
+
failed += batch_failed
|
|
695
|
+
print(f"archive-done: {len(succeeded)} of {len(done_items)} Done items archived")
|
|
696
|
+
for item_id, message in failed:
|
|
697
|
+
print(f" ERROR {item_id}: {message}")
|
|
698
|
+
return 1 if failed else 0
|
|
699
|
+
|
|
700
|
+
|
|
544
701
|
def cmd_parent_of(args) -> int:
|
|
545
702
|
parent = _parent_of(args.issue)
|
|
546
703
|
print(parent if parent is not None else "FREI")
|
|
@@ -793,6 +950,13 @@ def cmd_promote(args) -> int:
|
|
|
793
950
|
if mismatch:
|
|
794
951
|
print(f"error: {mismatch}", file=sys.stderr)
|
|
795
952
|
return 1
|
|
953
|
+
search = _gh_json(["api", "-X", "GET", "search/issues",
|
|
954
|
+
"-f", f'q=repo:{REPO} is:issue in:title "{WAVE_TITLE_PREFIX} {args.wave}"',
|
|
955
|
+
"-f", "per_page=100"])
|
|
956
|
+
collision = wave_collision_guard(search, args.wave, args.issue)
|
|
957
|
+
if collision:
|
|
958
|
+
print(f"error: {collision}", file=sys.stderr)
|
|
959
|
+
return 1
|
|
796
960
|
done: list[str] = []
|
|
797
961
|
try:
|
|
798
962
|
strip = type_labels_to_strip(labels)
|
|
@@ -1089,7 +1253,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1089
1253
|
p = argparse.ArgumentParser(prog="board-sync.py", description=__doc__.splitlines()[0])
|
|
1090
1254
|
sub = p.add_subparsers(dest="command", required=True)
|
|
1091
1255
|
|
|
1092
|
-
sub.add_parser("next-wave", help="print the next monotone wave number"
|
|
1256
|
+
nw = sub.add_parser("next-wave", help="print the next monotone wave number "
|
|
1257
|
+
"(cheap title search; --scan reads the full board)")
|
|
1258
|
+
nw.add_argument("--scan", action="store_true")
|
|
1259
|
+
nw.set_defaults(func=cmd_next_wave)
|
|
1260
|
+
|
|
1261
|
+
archive = sub.add_parser(
|
|
1262
|
+
"archive-done",
|
|
1263
|
+
help="preview archival of active Done items; --apply performs bounded batches",
|
|
1264
|
+
)
|
|
1265
|
+
archive.add_argument("--apply", action="store_true",
|
|
1266
|
+
help="perform archival (the default is a non-mutating dry-run)")
|
|
1267
|
+
archive.set_defaults(func=cmd_archive_done)
|
|
1268
|
+
|
|
1269
|
+
item = sub.add_parser("item-of", help="targeted item id and field lookup for one issue")
|
|
1270
|
+
item.add_argument("--issue", type=int, required=True)
|
|
1271
|
+
item.set_defaults(func=cmd_item_of)
|
|
1093
1272
|
|
|
1094
1273
|
po = sub.add_parser("parent-of", help="print an issue's parent number or FREI")
|
|
1095
1274
|
po.add_argument("issue", type=int)
|
package/scripts/pr-body-check.py
CHANGED
|
@@ -4,7 +4,7 @@ pr-body-check.py — mechanical guard for the PR-body conventions that were unti
|
|
|
4
4
|
now prose-only.
|
|
5
5
|
|
|
6
6
|
Called by `wrapup` Step 0c AFTER the PR has been created/reused, BEFORE the
|
|
7
|
-
merge gate. Turns
|
|
7
|
+
merge gate. Turns four instruction-only rules into a check that actually fires:
|
|
8
8
|
|
|
9
9
|
1. Anker-Slice (Issue HAS a parent) → body MUST contain `Part of #<parent>`
|
|
10
10
|
and MUST NOT contain a close-keyword on the **parent anchor** number
|
|
@@ -16,9 +16,12 @@ merge gate. Turns three instruction-only rules into a check that actually fires:
|
|
|
16
16
|
auto-close never fires, wrapup Step 5b lesson).
|
|
17
17
|
3. `**Retro:**`-Pflichtzeile present in one of the Slice-7 forms
|
|
18
18
|
(`gefahren …` | `übersprungen …`), with a space after the marker.
|
|
19
|
+
4. Exactly one valid `E2E-NA: <reason>` trailer in the immutable pull-request
|
|
20
|
+
range requires active `E2E: n/a — <reason>` PR-body evidence.
|
|
19
21
|
|
|
20
|
-
Scope: this script checks ONLY the closes/Part-of anchor rule
|
|
21
|
-
`**Retro:**` line. It does NOT parse or validate
|
|
22
|
+
Scope: this script checks ONLY the closes/Part-of anchor rule, the
|
|
23
|
+
`**Retro:**` line, and E2E exemption evidence. It does NOT parse or validate
|
|
24
|
+
`annahme-drift` markers
|
|
22
25
|
(those are prose-/judgment-driven in wrapup Step 0c + 5e, deliberately not
|
|
23
26
|
mechanised — R2-F6). The annahme-drift block therefore runs BEFORE this
|
|
24
27
|
check in wrapup Step 0c so the body the script sees is final.
|
|
@@ -34,7 +37,7 @@ thin shell. NOT a hook — `wrapup` invokes it (Design).
|
|
|
34
37
|
|
|
35
38
|
Usage:
|
|
36
39
|
pr-body-check.py [--branch <name>] [--issue <n>] [--parent <n>|FREI]
|
|
37
|
-
[--body-file <path>]
|
|
40
|
+
[--body-file <path>] [--base-sha <sha>] [--head-sha <sha>]
|
|
38
41
|
All flags optional; unset → derived from the current branch + live PR via gh.
|
|
39
42
|
|
|
40
43
|
Audit log: .claude/logs/pr-body-check.log
|
|
@@ -47,6 +50,11 @@ from pathlib import Path
|
|
|
47
50
|
|
|
48
51
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
49
52
|
from board_config import ConfigError, load_board_config # noqa: E402
|
|
53
|
+
from pr_body_e2e import ( # noqa: E402
|
|
54
|
+
check_e2e_na_line,
|
|
55
|
+
fetch_has_e2e_na_trailer,
|
|
56
|
+
fetch_pr_range,
|
|
57
|
+
)
|
|
50
58
|
|
|
51
59
|
try:
|
|
52
60
|
_CFG = load_board_config()
|
|
@@ -122,7 +130,8 @@ def _part_of(body: str, n: int) -> bool:
|
|
|
122
130
|
rf"\b{PART_OF_RE_SRC}(?::\s*|\s+)#0*{n}(?!\d)", body or "", re.IGNORECASE))
|
|
123
131
|
|
|
124
132
|
|
|
125
|
-
def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False
|
|
133
|
+
def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False,
|
|
134
|
+
has_e2e_na_trailer: bool = False):
|
|
126
135
|
"""Return a list of violation strings ([] = green).
|
|
127
136
|
|
|
128
137
|
parent is the anchor issue number, or None for an atomar Leaf.
|
|
@@ -165,6 +174,7 @@ def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False):
|
|
|
165
174
|
"Pflichtzeile `**Retro:** gefahren — …` oder "
|
|
166
175
|
"`**Retro:** übersprungen — <Grund>` fehlt.")
|
|
167
176
|
|
|
177
|
+
violations.extend(check_e2e_na_line(body, has_e2e_na_trailer))
|
|
168
178
|
return violations
|
|
169
179
|
|
|
170
180
|
|
|
@@ -207,12 +217,23 @@ def fetch_is_anchor(number: int) -> bool:
|
|
|
207
217
|
return "type:cluster" in out.splitlines()
|
|
208
218
|
|
|
209
219
|
|
|
220
|
+
def resolve_has_e2e_na(args, branch: str) -> bool:
|
|
221
|
+
"""Explicit test overrides win; otherwise use immutable GitHub PR SHAs."""
|
|
222
|
+
if args.base_sha or args.head_sha:
|
|
223
|
+
base_sha, head_sha = args.base_sha, args.head_sha
|
|
224
|
+
else:
|
|
225
|
+
base_sha, head_sha = fetch_pr_range(branch)
|
|
226
|
+
return fetch_has_e2e_na_trailer(base_sha, head_sha)
|
|
227
|
+
|
|
228
|
+
|
|
210
229
|
def main() -> int:
|
|
211
230
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
|
212
231
|
ap.add_argument("--branch", help="default: current git branch")
|
|
213
232
|
ap.add_argument("--issue", type=int, help="override branch-derived issue #")
|
|
214
233
|
ap.add_argument("--parent", help="override parent (number or FREI)")
|
|
215
234
|
ap.add_argument("--body-file", help="read PR body from file instead of gh")
|
|
235
|
+
ap.add_argument("--base-sha", help="override immutable PR base SHA")
|
|
236
|
+
ap.add_argument("--head-sha", help="override immutable PR head SHA")
|
|
216
237
|
args = ap.parse_args()
|
|
217
238
|
|
|
218
239
|
branch = args.branch or current_branch()
|
|
@@ -244,7 +265,14 @@ def main() -> int:
|
|
|
244
265
|
parent = fetch_parent(issue)
|
|
245
266
|
|
|
246
267
|
is_anchor = fetch_is_anchor(issue) if parent is None else False
|
|
247
|
-
|
|
268
|
+
has_e2e_na = resolve_has_e2e_na(args, branch)
|
|
269
|
+
violations = check_pr_body(
|
|
270
|
+
body,
|
|
271
|
+
issue,
|
|
272
|
+
parent,
|
|
273
|
+
is_anchor=is_anchor,
|
|
274
|
+
has_e2e_na_trailer=has_e2e_na,
|
|
275
|
+
)
|
|
248
276
|
kind = ("Anker-Slice" if parent is not None
|
|
249
277
|
else "Wave-PR" if is_anchor else "Leaf")
|
|
250
278
|
log(f"branch={branch} issue={issue} parent={parent} kind={kind} "
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Couple a valid E2E-NA trailer to active PR-body evidence."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
E2E_NA_LINE_RE = re.compile(
|
|
11
|
+
r"^E2E:\s*n/a\s*—\s*\S", re.IGNORECASE | re.MULTILINE
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run(cmd: list[str], timeout=15):
|
|
16
|
+
try:
|
|
17
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
18
|
+
return result.returncode, result.stdout.strip()
|
|
19
|
+
except Exception:
|
|
20
|
+
return -1, ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_e2e_na_line(body: str, has_e2e_na_trailer: bool) -> list[str]:
|
|
24
|
+
"""Require non-empty body evidence only when a valid trailer exists."""
|
|
25
|
+
if not has_e2e_na_trailer or E2E_NA_LINE_RE.search(body or ""):
|
|
26
|
+
return []
|
|
27
|
+
return [
|
|
28
|
+
"E2E-NA trailer found in the pull-request range, but required "
|
|
29
|
+
"`E2E: n/a — <reason>` evidence is missing from the PR body."
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git(args: list[str], cwd=None) -> str:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=15
|
|
36
|
+
)
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
raise RuntimeError(result.stderr.strip())
|
|
39
|
+
return result.stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _collect_e2e_na_trailers(base_sha: str, head_sha: str, cwd=None) -> list[str]:
|
|
43
|
+
raw = _git(
|
|
44
|
+
[
|
|
45
|
+
"log",
|
|
46
|
+
f"{base_sha}..{head_sha}",
|
|
47
|
+
"--format=%x00%(trailers:key=E2E-NA,unfold)",
|
|
48
|
+
],
|
|
49
|
+
cwd=cwd,
|
|
50
|
+
)
|
|
51
|
+
values = []
|
|
52
|
+
for chunk in raw.split("\x00"):
|
|
53
|
+
for line in chunk.splitlines():
|
|
54
|
+
if line.startswith("E2E-NA:"):
|
|
55
|
+
values.append(line.removeprefix("E2E-NA:").strip())
|
|
56
|
+
return values
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fetch_has_e2e_na_trailer(base_sha, head_sha, cwd=None) -> bool:
|
|
60
|
+
"""Return true for exactly one non-empty trailer; unreadable ranges are false."""
|
|
61
|
+
if not base_sha or not head_sha:
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
values = _collect_e2e_na_trailers(base_sha, head_sha, cwd=cwd)
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
return len(values) == 1 and bool(values[0].strip())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fetch_pr_range(branch: str):
|
|
71
|
+
"""Return GitHub's immutable PR base/head SHAs, or an unreadable range."""
|
|
72
|
+
rc, out = _run(
|
|
73
|
+
["gh", "pr", "view", branch, "--json", "baseRefOid,headRefOid"]
|
|
74
|
+
)
|
|
75
|
+
if rc != 0 or not out:
|
|
76
|
+
return None, None
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(out)
|
|
79
|
+
except (json.JSONDecodeError, TypeError):
|
|
80
|
+
return None, None
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
return None, None
|
|
83
|
+
return data.get("baseRefOid"), data.get("headRefOid")
|
|
@@ -50,3 +50,37 @@ export function assertReleaseParity(identities) {
|
|
|
50
50
|
}
|
|
51
51
|
return identities.local;
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
// Consumer-side identity: an unpacked installation cannot be re-packed
|
|
55
|
+
// byte-identically (npm normalizes at publish/unpack), so the installed copy
|
|
56
|
+
// proves itself by content — never by a fresh tarball hash.
|
|
57
|
+
const INSTALLED_FIELDS = ['name', 'version', 'manifestSha256'];
|
|
58
|
+
|
|
59
|
+
export async function installedIdentityFromDir(kitRoot) {
|
|
60
|
+
const packageJson = JSON.parse(await readFile(`${kitRoot}/package.json`, 'utf8'));
|
|
61
|
+
const manifest = await readFile(`${kitRoot}/agent-workflow-kit.package.json`);
|
|
62
|
+
if (packageJson.version !== JSON.parse(manifest).kitVersion) {
|
|
63
|
+
throw new Error('installed package and manifest versions mismatch');
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
name: packageJson.name,
|
|
67
|
+
version: packageJson.version,
|
|
68
|
+
manifestSha256: digest('sha256', manifest),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assertConsumerReleaseParity({ installed, npm, github }) {
|
|
73
|
+
validate('npm', npm);
|
|
74
|
+
validate('github', github);
|
|
75
|
+
for (const field of FIELDS) {
|
|
76
|
+
if (github[field] !== npm[field]) throw new Error(`github ${field} mismatch`);
|
|
77
|
+
}
|
|
78
|
+
if (!installed || typeof installed !== 'object') throw new Error('installed release identity missing');
|
|
79
|
+
for (const field of INSTALLED_FIELDS) {
|
|
80
|
+
if (typeof installed[field] !== 'string' || !installed[field]) {
|
|
81
|
+
throw new Error(`installed ${field} missing`);
|
|
82
|
+
}
|
|
83
|
+
if (installed[field] !== npm[field]) throw new Error(`installed ${field} mismatch`);
|
|
84
|
+
}
|
|
85
|
+
return npm;
|
|
86
|
+
}
|
|
@@ -51,3 +51,40 @@ test('a package tarball produces a deterministic content identity', async () =>
|
|
|
51
51
|
assert.deepEqual(first, second);
|
|
52
52
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test('consumer parity proves npm↔github and matches the installed copy without re-packing', async () => {
|
|
56
|
+
const { assertConsumerReleaseParity } = await import('./release-parity.mjs');
|
|
57
|
+
const installed = { name: identity.name, version: identity.version, manifestSha256: identity.manifestSha256 };
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
assertConsumerReleaseParity({ installed, npm: { ...identity }, github: { ...identity } }),
|
|
60
|
+
identity,
|
|
61
|
+
);
|
|
62
|
+
assert.throws(() => assertConsumerReleaseParity({
|
|
63
|
+
installed, npm: { ...identity }, github: { ...identity, tarballIntegrity: 'different' },
|
|
64
|
+
}), /github tarballIntegrity mismatch/);
|
|
65
|
+
assert.throws(() => assertConsumerReleaseParity({
|
|
66
|
+
installed: { ...installed, manifestSha256: 'different' },
|
|
67
|
+
npm: { ...identity }, github: { ...identity },
|
|
68
|
+
}), /installed manifestSha256 mismatch/);
|
|
69
|
+
assert.throws(() => assertConsumerReleaseParity({
|
|
70
|
+
installed: { ...installed, version: '9.9.9' },
|
|
71
|
+
npm: { ...identity }, github: { ...identity },
|
|
72
|
+
}), /installed version mismatch/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an installed kit directory yields a content identity without npm pack', async () => {
|
|
76
|
+
const { installedIdentityFromDir } = await import('./release-parity.mjs');
|
|
77
|
+
const root = await mkdtemp(join(tmpdir(), 'release-parity-installed-'));
|
|
78
|
+
try {
|
|
79
|
+
const manifest = JSON.stringify({ kitVersion: '1.2.3', files: [] });
|
|
80
|
+
await writeFile(join(root, 'package.json'), JSON.stringify({ name: identity.name, version: '1.2.3' }));
|
|
81
|
+
await writeFile(join(root, 'agent-workflow-kit.package.json'), manifest);
|
|
82
|
+
const installed = await installedIdentityFromDir(root);
|
|
83
|
+
assert.equal(installed.name, identity.name);
|
|
84
|
+
assert.equal(installed.version, '1.2.3');
|
|
85
|
+
assert.equal(typeof installed.manifestSha256, 'string');
|
|
86
|
+
assert.equal('tarballIntegrity' in installed, false);
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(root, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|