@ikon85/agent-workflow-kit 0.27.1 → 0.29.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.
Files changed (79) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/board-to-waves/SKILL.md +13 -3
  3. package/.agents/skills/grill-me/SKILL.md +1 -1
  4. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  5. package/.agents/skills/kit-update/SKILL.md +12 -0
  6. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  7. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  8. package/.agents/skills/retro/SKILL.md +24 -1
  9. package/.agents/skills/scale-check/SKILL.md +2 -2
  10. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  11. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  12. package/.agents/skills/tdd/SKILL.md +68 -14
  13. package/.agents/skills/to-issues/SKILL.md +104 -29
  14. package/.agents/skills/to-prd/SKILL.md +13 -3
  15. package/.agents/skills/to-waves/SKILL.md +16 -4
  16. package/.agents/skills/verify-spike/SKILL.md +1 -1
  17. package/.agents/skills/wrapup/SKILL.md +14 -3
  18. package/.claude/hooks/drift-guard.py +5 -2
  19. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  20. package/.claude/skills/ask-matt/SKILL.md +2 -2
  21. package/.claude/skills/board-to-waves/SKILL.md +13 -3
  22. package/.claude/skills/codex-build/SKILL.md +69 -23
  23. package/.claude/skills/codex-review/SKILL.md +47 -37
  24. package/.claude/skills/grill-me/SKILL.md +1 -1
  25. package/.claude/skills/grill-me-codex/SKILL.md +45 -34
  26. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  27. package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
  28. package/.claude/skills/kit-update/SKILL.md +12 -0
  29. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  30. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  31. package/.claude/skills/retro/SKILL.md +23 -0
  32. package/.claude/skills/scale-check/SKILL.md +2 -2
  33. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  34. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  35. package/.claude/skills/skill-manifest.json +1 -1
  36. package/.claude/skills/tdd/SKILL.md +68 -14
  37. package/.claude/skills/to-issues/SKILL.md +104 -29
  38. package/.claude/skills/to-prd/SKILL.md +13 -3
  39. package/.claude/skills/to-waves/SKILL.md +16 -4
  40. package/.claude/skills/verify-spike/SKILL.md +1 -1
  41. package/.claude/skills/wrapup/SKILL.md +14 -3
  42. package/README.md +70 -7
  43. package/agent-workflow-kit.package.json +99 -43
  44. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  45. package/docs/agents/wave-anchor-template.md +1 -1
  46. package/docs/research/wave-152-consumer-acceptance.md +98 -0
  47. package/package.json +1 -1
  48. package/scripts/board-sync.py +187 -12
  49. package/scripts/build-kit.test.mjs +42 -1
  50. package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
  51. package/scripts/codex-exec.sh +566 -0
  52. package/scripts/codex-exec.test.mjs +815 -0
  53. package/scripts/codex_proc.py +602 -0
  54. package/scripts/find-by-marker.py +68 -0
  55. package/scripts/marker_lib.py +88 -0
  56. package/scripts/pr-body-check.py +34 -6
  57. package/scripts/pr_body_e2e.py +83 -0
  58. package/scripts/render-anchor.py +111 -0
  59. package/scripts/test_board_sync.py +208 -0
  60. package/scripts/test_census_backstop.py +56 -3
  61. package/scripts/test_marker_lib.py +154 -0
  62. package/scripts/test_orchestrate_wave_contract.py +116 -0
  63. package/scripts/test_pr_body_check.py +245 -0
  64. package/scripts/test_render_anchor.py +267 -0
  65. package/scripts/test_retro_wrapup_contract.py +117 -0
  66. package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
  67. package/scripts/test_tdd_contract.py +78 -0
  68. package/src/cli.mjs +44 -8
  69. package/src/commands/diff.mjs +5 -2
  70. package/src/commands/init.mjs +12 -0
  71. package/src/commands/own.mjs +16 -0
  72. package/src/commands/uninstall.mjs +8 -1
  73. package/src/commands/update.mjs +37 -9
  74. package/src/lib/bundle.mjs +14 -0
  75. package/src/lib/consumerPath.mjs +30 -0
  76. package/src/lib/manifest.mjs +18 -0
  77. package/src/lib/ownedDiff.mjs +88 -0
  78. package/src/lib/updateCandidate.mjs +14 -0
  79. package/src/lib/updateReconcile.mjs +45 -6
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Shared exact-value HTML marker lookup for issue identity."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from collections.abc import Callable
9
+
10
+
11
+ MARKER_KINDS = frozenset({
12
+ "prd-source-id",
13
+ "program-leaf-source",
14
+ "program-stub-source",
15
+ "wave-stub-source",
16
+ })
17
+
18
+
19
+ class UnknownMarkerKind(ValueError):
20
+ """Raised when a caller requests a marker outside the public grammar."""
21
+
22
+
23
+ def marker_value(body: str, kind: str) -> str | None:
24
+ """Return one allowlisted marker value from a body, trimmed exactly once."""
25
+ if kind not in MARKER_KINDS:
26
+ raise UnknownMarkerKind(f"unknown marker kind: {kind}")
27
+ match = re.search(rf"<!--\s*{re.escape(kind)}:\s*([^>]+?)\s*-->", body)
28
+ return match.group(1).strip() if match else None
29
+
30
+
31
+ def first_marker(body: str, kinds: tuple[str, ...]) -> tuple[str, str] | None:
32
+ """Return the first body-ordered marker among validated allowlisted kinds."""
33
+ unknown = next((kind for kind in kinds if kind not in MARKER_KINDS), None)
34
+ if unknown:
35
+ raise UnknownMarkerKind(f"unknown marker kind: {unknown}")
36
+ alternatives = "|".join(re.escape(kind) for kind in kinds)
37
+ match = re.search(rf"<!--\s*({alternatives}):\s*([^>]+?)\s*-->", body)
38
+ return (match.group(1), match.group(2).strip()) if match else None
39
+
40
+
41
+ def _issues_from_pages(raw: str) -> list[dict]:
42
+ pages = json.loads(raw)
43
+ if not pages:
44
+ return []
45
+ if isinstance(pages[0], dict):
46
+ return pages
47
+ return [issue for page in pages for issue in page]
48
+
49
+
50
+ def marker_verdict(issues: list[dict]) -> str:
51
+ """Map exact identity matches onto the four-way consumer decision."""
52
+ if not issues:
53
+ return "create"
54
+ if len(issues) > 1:
55
+ return "STOP"
56
+ return "update" if issues[0]["state"] == "open" else "user-decision"
57
+
58
+
59
+ def find_by_marker(repo: str, kind: str, slug: str,
60
+ gh: Callable[[list[str]], str]) -> dict:
61
+ """Scan every issue state and return exact marker matches plus a verdict."""
62
+ marker_value("", kind) # validate before making a network call
63
+ raw = gh([
64
+ "api", "--paginate", "--slurp", "--method", "GET",
65
+ f"repos/{repo}/issues", "-f", "state=all", "-f", "per_page=100",
66
+ ])
67
+ matches = [
68
+ {"number": issue["number"], "state": issue["state"]}
69
+ for issue in _issues_from_pages(raw)
70
+ if "pull_request" not in issue
71
+ and marker_value(issue.get("body") or "", kind) == slug
72
+ ]
73
+ return {
74
+ "count": len(matches),
75
+ "issues": matches,
76
+ "verdict": marker_verdict(matches),
77
+ }
78
+
79
+
80
+ def reconcile_after_create(repo: str, kind: str, slug: str,
81
+ created_number: int,
82
+ gh: Callable[[list[str]], str]) -> dict:
83
+ """Re-scan after create and fail closed unless that open issue is unique."""
84
+ result = find_by_marker(repo, kind, slug, gh)
85
+ expected = [{"number": created_number, "state": "open"}]
86
+ if result["issues"] != expected:
87
+ result["verdict"] = "STOP"
88
+ return result
@@ -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 three instruction-only rules into a check that actually fires:
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 and the
21
- `**Retro:**` line. It does NOT parse or validate `annahme-drift` markers
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
- violations = check_pr_body(body, issue, parent, is_anchor=is_anchor)
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")
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Pure rendering for a lean Tier-2 anchor and its full PRD archive."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import NamedTuple
11
+
12
+ from marker_lib import marker_value
13
+
14
+
15
+ PLAN_REVISION_RE = re.compile(
16
+ r"^\s*\*\*plan_revision:\*\*\s*(r\d+)\s*$"
17
+ )
18
+ PLAN_REVISION_LOOKALIKE_RE = re.compile(r"^\s*\*\*plan_revision:\*\*.*$")
19
+ HTML_MARKER_RE = re.compile(
20
+ r"^\s*<!--\s*(prd-source-id|prd-content-fp|prd):\s*([^>\r\n]+?)\s*-->\s*$"
21
+ )
22
+ HTML_COMMENT_RE = re.compile(r"^\s*<!--.*-->\s*$")
23
+
24
+
25
+ class RenderedDocuments(NamedTuple):
26
+ """The two byte-stable documents produced by one render."""
27
+
28
+ anchor_body: str
29
+ archive_body: str
30
+
31
+
32
+ def _canonical_marker(line: str) -> tuple[str, str] | None:
33
+ revision = PLAN_REVISION_RE.fullmatch(line)
34
+ if revision:
35
+ return ("plan_revision", revision.group(1))
36
+ marker = HTML_MARKER_RE.fullmatch(line)
37
+ if not marker:
38
+ return None
39
+ kind = marker.group(1)
40
+ if kind == "prd-source-id":
41
+ value = marker_value(line, kind)
42
+ return (kind, value) if value is not None else None
43
+ return (kind, "")
44
+
45
+
46
+ def _strip_head_markers(source: str) -> tuple[list[str], str]:
47
+ revisions: list[str] = []
48
+ end = 0
49
+ kept: list[str] = []
50
+ for line in source.splitlines(keepends=True):
51
+ bare = line.rstrip("\r\n")
52
+ marker = _canonical_marker(bare)
53
+ if (
54
+ not bare.strip()
55
+ or marker
56
+ or HTML_COMMENT_RE.fullmatch(bare)
57
+ or PLAN_REVISION_LOOKALIKE_RE.fullmatch(bare)
58
+ ):
59
+ end += len(line)
60
+ if marker:
61
+ if marker[0] == "plan_revision":
62
+ revisions.append(marker[1])
63
+ else:
64
+ kept.append(line)
65
+ continue
66
+ break
67
+ if not any(line.strip() for line in kept):
68
+ kept = []
69
+ while kept and not kept[0].strip():
70
+ kept.pop(0)
71
+ return revisions, "".join(kept) + source[end:]
72
+
73
+
74
+ def render_documents(anchor_template: str, prd_body: str) -> RenderedDocuments:
75
+ """Return the filled anchor template and marker-free PRD archive."""
76
+ revisions, archive_source = _strip_head_markers(prd_body)
77
+ if len(revisions) != 1:
78
+ raise ValueError("source PRD head must have exactly one canonical plan_revision")
79
+ revision = revisions[0]
80
+ archive_header = (
81
+ f"📄 Full PRD (archive, {revision}) — "
82
+ "the body carries navigation/decisions only\n\n"
83
+ )
84
+ return RenderedDocuments(anchor_template, archive_header + archive_source)
85
+
86
+
87
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
88
+ parser = argparse.ArgumentParser(
89
+ description="Render one filled Tier-2 anchor or its marker-free PRD archive."
90
+ )
91
+ parser.add_argument("--template", type=Path, required=True)
92
+ parser.add_argument("--prd", type=Path, required=True)
93
+ parser.add_argument("--document", choices=("anchor", "archive"), required=True)
94
+ return parser.parse_args(argv)
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ args = _parse_args(argv)
99
+ rendered = render_documents(
100
+ args.template.read_bytes().decode("utf-8"),
101
+ args.prd.read_bytes().decode("utf-8"),
102
+ )
103
+ output = (
104
+ rendered.anchor_body if args.document == "anchor" else rendered.archive_body
105
+ )
106
+ sys.stdout.buffer.write(output.encode("utf-8"))
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Behavior tests for bounded board operations; the fake seam never calls GitHub."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import json
8
+ import subprocess
9
+ import unittest
10
+ from contextlib import redirect_stderr, redirect_stdout
11
+ from unittest.mock import patch
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+
16
+
17
+ SPEC = importlib.util.spec_from_file_location(
18
+ "board_sync_operations_test", Path(__file__).with_name("board-sync.py"))
19
+ bs = importlib.util.module_from_spec(SPEC)
20
+ assert SPEC.loader is not None
21
+ SPEC.loader.exec_module(bs)
22
+
23
+
24
+ class FakeGh:
25
+ def __init__(self, responses=None, failures=None):
26
+ self.responses = responses or {}
27
+ self.failures = failures or {}
28
+ self.calls = []
29
+
30
+ def __call__(self, args):
31
+ self.calls.append(args)
32
+ joined = " ".join(args)
33
+ for needle, body in self.failures.items():
34
+ if needle in joined:
35
+ error = bs.GhError("injected failure")
36
+ error.stdout = body
37
+ raise error
38
+ for needle, body in self.responses.items():
39
+ if needle in joined:
40
+ return body
41
+ return ""
42
+
43
+
44
+ def run(fake, argv):
45
+ old = bs._gh
46
+ bs._gh = fake
47
+ out = io.StringIO()
48
+ try:
49
+ with redirect_stdout(out):
50
+ code = bs.main(argv)
51
+ finally:
52
+ bs._gh = old
53
+ return code, out.getvalue()
54
+
55
+
56
+ class BoundedCalls(unittest.TestCase):
57
+ def test_full_board_and_ordinary_calls_have_distinct_clear_timeouts(self):
58
+ with patch.object(bs.subprocess, "run", side_effect=subprocess.TimeoutExpired("gh", 1)) as call:
59
+ with self.assertRaisesRegex(bs.GhError, "15s"):
60
+ bs._gh(["issue", "view", "1"])
61
+ self.assertEqual(call.call_args.kwargs["timeout"], 15)
62
+ with self.assertRaisesRegex(bs.GhError, "60s"):
63
+ bs._gh(["project", "item-list", "3"])
64
+ self.assertEqual(call.call_args.kwargs["timeout"], 60)
65
+
66
+ def test_partial_create_prints_issue_and_replay_safe_repair(self):
67
+ fake = FakeGh(
68
+ {"issue list": "[]", "issue create": "https://github.com/x/y/issues/17\n"},
69
+ {"project item-add": ""},
70
+ )
71
+ with patch.object(bs, "REPO", "x/y"):
72
+ with self.subTest("created issue remains visible"):
73
+ from tempfile import NamedTemporaryFile
74
+ with NamedTemporaryFile("w") as body:
75
+ body.write("<!-- program-leaf-source: p/1 -->\n")
76
+ body.flush()
77
+ code, out = run(fake, ["create", "--title", "S", "--body-file", body.name])
78
+ self.assertEqual(code, 1)
79
+ self.assertIn("#17 https://github.com/x/y/issues/17", out)
80
+ self.assertIn("board-sync.py add --issue 17", out)
81
+ self.assertIn("idempotent", out)
82
+
83
+
84
+ class WaveLookup(unittest.TestCase):
85
+ def test_search_finds_next_wave_without_full_scan(self):
86
+ fake = FakeGh({"search/issues": json.dumps({"items": [
87
+ {"title": f"{bs.WAVE_TITLE_PREFIX} 7 — Anchor"},
88
+ {"title": f"{bs.WAVE_TITLE_PREFIX} 8 / Slice 1 — Leaf"},
89
+ ]})})
90
+ code, out = run(fake, ["next-wave"])
91
+ self.assertEqual((code, out.strip()), (0, "9"))
92
+ self.assertFalse(any("item-list" in " ".join(c) for c in fake.calls))
93
+
94
+ def test_empty_search_falls_back_and_scan_skips_search(self):
95
+ fake = FakeGh({"search/issues": '{"items":[]}', "item-list": '{"items":[{"wave":4}]}'})
96
+ with redirect_stderr(io.StringIO()):
97
+ self.assertEqual(run(fake, ["next-wave"])[1].strip(), "5")
98
+ scan = FakeGh({"item-list": '{"items":[{"wave":9}]}'})
99
+ self.assertEqual(run(scan, ["next-wave", "--scan"])[1].strip(), "10")
100
+ self.assertFalse(any("search/issues" in " ".join(c) for c in scan.calls))
101
+
102
+ def test_promotion_refuses_foreign_anchor_but_allows_same_issue(self):
103
+ foreign = {"items": [{"number": 22, "title": f"{bs.WAVE_TITLE_PREFIX} 7 — Other"}]}
104
+ message = bs.wave_collision_guard(foreign, 7, 21)
105
+ self.assertIn("#22", message)
106
+ self.assertIn("next-wave", message)
107
+ self.assertIsNone(bs.wave_collision_guard(foreign, 7, 22))
108
+ leaf = {"items": [{"number": 23, "title": f"{bs.WAVE_TITLE_PREFIX} 7 / Slice 1 — X"}]}
109
+ self.assertIsNone(bs.wave_collision_guard(leaf, 7, 21))
110
+
111
+ def test_promote_collision_fails_before_any_write(self):
112
+ fake = FakeGh({
113
+ "--json labels": "",
114
+ "--json body": "Draft PRD\n",
115
+ "graphql": '{"data":{"repository":{"issue":{"projectItems":{"nodes":[]}}}}}',
116
+ "search/issues": json.dumps({"items": [
117
+ {"number": 22, "title": f"{bs.WAVE_TITLE_PREFIX} 7 — Other"},
118
+ ]}),
119
+ })
120
+ error = io.StringIO()
121
+ with redirect_stderr(error):
122
+ code, _ = run(fake, ["promote", "--issue", "21", "--wave", "7"])
123
+ self.assertEqual(code, 1)
124
+ self.assertIn("#22", error.getvalue())
125
+ self.assertFalse(any(c[:2] == ["issue", "edit"] for c in fake.calls))
126
+
127
+
128
+ class TargetedLookup(unittest.TestCase):
129
+ @staticmethod
130
+ def payload(project):
131
+ return {"data": {"repository": {"issue": {"projectItems": {"nodes": [{
132
+ "id": "PVTI_x", "project": {"id": project}, "fieldValues": {"nodes": [
133
+ {"number": 7, "field": {"id": bs.WAVE_FIELD_ID}},
134
+ {"name": "Spec", "field": {"id": bs.STATUS_FIELD_ID}},
135
+ ]},
136
+ }]}}}}}
137
+
138
+ def test_item_of_returns_configured_fields(self):
139
+ fake = FakeGh({"graphql": json.dumps(self.payload(bs.PROJECT_NODE_ID))})
140
+ code, out = run(fake, ["item-of", "--issue", "21"])
141
+ self.assertEqual(code, 0)
142
+ self.assertEqual(json.loads(out), {"itemId": "PVTI_x", "wave": 7,
143
+ "status": "Spec", "cluster": None})
144
+
145
+ def test_item_of_fails_clearly_when_absent(self):
146
+ payload = {"data": {"repository": {"issue": {"projectItems": {"nodes": []}}}}}
147
+ code, out = run(FakeGh({"graphql": json.dumps(payload)}),
148
+ ["item-of", "--issue", "99"])
149
+ self.assertEqual((code, out.strip()), (1, "NOT-ON-BOARD"))
150
+
151
+
152
+ class ArchiveDone(unittest.TestCase):
153
+ def item_payload(self, count=1):
154
+ items = [{"id": f"D{i}", "status": bs.STATUS_ROLES["done"]} for i in range(count)]
155
+ items += [{"id": "OPEN", "status": bs.STATUS_ROLES["inProgress"]},
156
+ {"id": "OLD", "status": bs.STATUS_ROLES["done"], "archived": True}]
157
+ return json.dumps({"items": items, "totalCount": len(items)})
158
+
159
+ def test_archive_defaults_to_dry_run_and_selects_only_active_done(self):
160
+ fake = FakeGh({"item-list": self.item_payload()})
161
+ code, out = run(fake, ["archive-done"])
162
+ self.assertEqual(code, 0)
163
+ self.assertIn("D0", out)
164
+ self.assertNotIn("OPEN", out)
165
+ self.assertNotIn("OLD", out)
166
+ self.assertFalse(any("archiveProjectV2Item" in " ".join(c) for c in fake.calls))
167
+
168
+ def test_apply_batches_at_thirty(self):
169
+ class BatchGh(FakeGh):
170
+ def __call__(self, args):
171
+ self.calls.append(args)
172
+ if "item-list" in args:
173
+ return self.responses["item-list"]
174
+ count = " ".join(args).count("archiveProjectV2Item")
175
+ return json.dumps({"data": {f"a{i}": {"item": {"id": f"x{i}"}}
176
+ for i in range(count)}})
177
+ fake = BatchGh({"item-list": self.item_payload(31)})
178
+ code, out = run(fake, ["archive-done", "--apply"])
179
+ self.assertEqual(code, 0)
180
+ self.assertIn("31 of 31", out)
181
+ calls = [c for c in fake.calls if "archiveProjectV2Item" in " ".join(c)]
182
+ self.assertEqual([" ".join(c).count("archiveProjectV2Item") for c in calls], [30, 1])
183
+
184
+ def test_truncated_input_refuses_without_mutation(self):
185
+ fake = FakeGh({"item-list": '{"items":[{"id":"D","status":"Done"}],"totalCount":2}'})
186
+ err = io.StringIO()
187
+ with redirect_stderr(err):
188
+ code, _ = run(fake, ["archive-done", "--apply"])
189
+ self.assertEqual(code, 1)
190
+ self.assertIn("partial archive", err.getvalue())
191
+ self.assertFalse(any("archiveProjectV2Item" in " ".join(c) for c in fake.calls))
192
+
193
+ def test_apply_reports_partial_failure_and_empty_replay_is_idempotent(self):
194
+ body = json.dumps({"data": {"a0": {"item": {"id": "D0"}}, "a1": None},
195
+ "errors": [{"path": ["a1"], "message": "already archived"}]})
196
+ fake = FakeGh({"item-list": self.item_payload(2)}, {"archiveProjectV2Item": body})
197
+ code, out = run(fake, ["archive-done", "--apply"])
198
+ self.assertEqual(code, 1)
199
+ self.assertIn("1 of 2", out)
200
+ self.assertIn("D1: already archived", out)
201
+ empty = FakeGh({"item-list": json.dumps({"items": [], "totalCount": 0})})
202
+ code, out = run(empty, ["archive-done", "--apply"])
203
+ self.assertEqual(code, 0)
204
+ self.assertIn("nothing to archive", out)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ unittest.main(verbosity=2)