@ikon85/agent-workflow-kit 0.38.0 → 0.40.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 (44) hide show
  1. package/.agents/skills/grill-me/SKILL.md +1 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  3. package/.agents/skills/kit-update/SKILL.md +33 -1
  4. package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
  5. package/.agents/skills/setup-workflow/SKILL.md +84 -3
  6. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
  9. package/.agents/skills/wrapup/SKILL.md +24 -0
  10. package/.claude/hooks/drift-guard.py +212 -21
  11. package/.claude/skills/grill-me/SKILL.md +1 -1
  12. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  13. package/.claude/skills/kit-update/SKILL.md +33 -1
  14. package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
  15. package/.claude/skills/setup-workflow/SKILL.md +84 -3
  16. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  17. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  18. package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
  19. package/.claude/skills/wrapup/SKILL.md +24 -0
  20. package/README.md +62 -0
  21. package/agent-workflow-kit.package.json +57 -25
  22. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
  23. package/package.json +1 -1
  24. package/scripts/board_bootstrap.py +367 -0
  25. package/scripts/kit-update-pr.mjs +20 -7
  26. package/scripts/kit-update-pr.test.mjs +29 -0
  27. package/scripts/profile_globs.py +347 -0
  28. package/scripts/test_board_bootstrap.py +348 -0
  29. package/scripts/test_drift_guard_diagnostics.py +295 -0
  30. package/scripts/test_profile_globs.py +280 -0
  31. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  32. package/scripts/test_worktree_ignore_seed.py +320 -0
  33. package/scripts/test_worktree_wrapup_contract.py +588 -0
  34. package/scripts/workflow-advisories/core.py +29 -4
  35. package/scripts/worktree-lifecycle/README.md +53 -4
  36. package/scripts/worktree-lifecycle/core.py +211 -60
  37. package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
  38. package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
  39. package/scripts/wrapup-land.py +179 -34
  40. package/src/cli.mjs +32 -1
  41. package/src/commands/update.mjs +30 -21
  42. package/src/consumer-migrations.json +19 -0
  43. package/src/lib/bundle.mjs +16 -0
  44. package/src/lib/consumerMigrations.mjs +161 -0
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ """One repository-relative glob dialect for every consumer workflow profile.
3
+
4
+ Workflow Advisories and Worktree Lifecycle both select repository-relative
5
+ paths from consumer-owned profile globs. They share this single matcher so the
6
+ same pattern can never select one set of paths for an advisory and a different
7
+ set for a cleanup decision.
8
+
9
+ Dialect
10
+ -------
11
+ - Paths and patterns are repository-relative POSIX paths separated by ``/``.
12
+ - ``*`` matches any run of characters inside one path segment, never ``/``.
13
+ - ``?`` matches exactly one character inside one path segment.
14
+ - ``[seq]`` and ``[!seq]`` are ``fnmatch`` character classes inside one
15
+ segment; ``/`` is always a separator and never a class member.
16
+ - ``**`` as a complete segment matches zero or more segments. A leading
17
+ ``**/`` therefore also matches the repository root, and ``dir/**`` also
18
+ matches ``dir`` itself.
19
+ - Matching is always case-sensitive, on every host filesystem.
20
+ - A pattern must match the whole path; there is no implicit prefix match.
21
+
22
+ Run this module to review an installed profile before trusting it:
23
+
24
+ python3 scripts/profile_globs.py [docs/agents/workflow-capabilities.json]
25
+
26
+ It names every pattern whose match set narrows or widens against the legacy
27
+ matcher of its own capability, marks the keys that carry deletion authority,
28
+ and never edits the profile. Exit code 0 means nothing changed meaning, 1 means
29
+ at least one pattern needs review, 2 means the profile could not be read.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import sys
36
+ from dataclasses import dataclass
37
+ from fnmatch import fnmatchcase
38
+ from pathlib import Path, PurePosixPath
39
+
40
+ WIDENS = "widens"
41
+ NARROWS = "narrows"
42
+ CASE_NARROWS = "narrows-on-case-insensitive-hosts"
43
+
44
+ DEFAULT_PROFILE = "docs/agents/workflow-capabilities.json"
45
+ DIALECT = (
46
+ "Repository-relative POSIX globs: `*` and `?` stay inside one path "
47
+ "segment, `[seq]`/`[!seq]` are per-segment character classes, `**` as a "
48
+ "whole segment matches zero or more segments (so a leading `**/` also "
49
+ "matches the repository root and `dir/**` also matches `dir`), matching "
50
+ "is always case-sensitive, and a pattern must match the whole path."
51
+ )
52
+ _SAMPLE_CHARS = "abcxyz0129_-"
53
+
54
+
55
+ def path_glob_matches(path: str, pattern: str) -> bool:
56
+ """Match POSIX path segments while retaining fnmatch bracket compatibility."""
57
+ path_parts = PurePosixPath(path).parts
58
+ pattern_parts = PurePosixPath(pattern).parts
59
+ memo: dict[tuple[int, int], bool] = {}
60
+
61
+ def matches(path_index: int, pattern_index: int) -> bool:
62
+ key = (path_index, pattern_index)
63
+ if key in memo:
64
+ return memo[key]
65
+ if pattern_index == len(pattern_parts):
66
+ result = path_index == len(path_parts)
67
+ elif pattern_parts[pattern_index] == "**":
68
+ result = matches(path_index, pattern_index + 1) or (
69
+ path_index < len(path_parts)
70
+ and matches(path_index + 1, pattern_index)
71
+ )
72
+ else:
73
+ result = (
74
+ path_index < len(path_parts)
75
+ and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index])
76
+ and matches(path_index + 1, pattern_index + 1)
77
+ )
78
+ memo[key] = result
79
+ return result
80
+
81
+ return matches(0, 0)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class PatternMigration:
86
+ """Proven differences between a legacy match set and the shared dialect."""
87
+
88
+ pattern: str
89
+ effects: tuple[str, ...]
90
+ witnesses: dict[str, str]
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ProfileGlobFinding:
95
+ location: str
96
+ pattern: str
97
+ deletion_authority: bool
98
+ migration: PatternMigration
99
+
100
+
101
+ def _class_end(segment: str, start: int) -> int | None:
102
+ index = start + 1
103
+ if index < len(segment) and segment[index] in "!^":
104
+ index += 1
105
+ if index < len(segment) and segment[index] == "]":
106
+ index += 1
107
+ while index < len(segment) and segment[index] != "]":
108
+ index += 1
109
+ return index if index < len(segment) else None
110
+
111
+
112
+ def _instantiate(segment: str, *, separator_filler: str | None = None) -> str | None:
113
+ """Build one concrete body that this single-segment pattern matches."""
114
+ body: list[str] = []
115
+ index = 0
116
+ used_separator = False
117
+ while index < len(segment):
118
+ character = segment[index]
119
+ if character in {"*", "?"}:
120
+ if separator_filler is not None and not used_separator:
121
+ body.append(separator_filler if character == "*" else "/")
122
+ used_separator = True
123
+ else:
124
+ body.append("x")
125
+ index += 1
126
+ elif character == "[":
127
+ end = _class_end(segment, index)
128
+ if end is None:
129
+ body.append(character)
130
+ index += 1
131
+ continue
132
+ member = next(
133
+ (c for c in _SAMPLE_CHARS if fnmatchcase(c, segment[index:end + 1])),
134
+ None,
135
+ )
136
+ if member is None:
137
+ return None
138
+ body.append(member)
139
+ index = end + 1
140
+ else:
141
+ body.append(character)
142
+ index += 1
143
+ if separator_filler is not None and not used_separator:
144
+ return None
145
+ return "".join(body)
146
+
147
+
148
+ def _pattern_parts(pattern: str) -> list[str]:
149
+ return [part for part in pattern.split("/") if part]
150
+
151
+
152
+ def _witness(
153
+ pattern: str, *, drop_globstar: bool = False, separator_part: int | None = None,
154
+ ) -> str | None:
155
+ """Instantiate one concrete repository-relative path from the pattern."""
156
+ segments: list[str] = []
157
+ for index, part in enumerate(_pattern_parts(pattern)):
158
+ if part == "**":
159
+ if drop_globstar:
160
+ continue
161
+ segments.append("x")
162
+ continue
163
+ body = _instantiate(
164
+ part, separator_filler="a/b" if index == separator_part else None,
165
+ )
166
+ if body is None:
167
+ return None
168
+ segments.append(body)
169
+ return "/".join(segments) if segments else "x"
170
+
171
+
172
+ def classify_pattern(
173
+ pattern: str, *, case_insensitive_legacy: bool = False,
174
+ ) -> PatternMigration:
175
+ """Name only differences proven by a concrete witness path."""
176
+ effects: list[str] = []
177
+ witnesses: dict[str, str] = {}
178
+ parts = _pattern_parts(pattern)
179
+ if any(part == "**" for part in parts):
180
+ witness = _witness(pattern, drop_globstar=True)
181
+ if (
182
+ witness
183
+ and path_glob_matches(witness, pattern)
184
+ and not fnmatchcase(witness, pattern)
185
+ ):
186
+ effects.append(WIDENS)
187
+ witnesses[WIDENS] = witness
188
+ for index, part in enumerate(parts):
189
+ if part == "**" or not ("*" in part or "?" in part):
190
+ continue
191
+ witness = _witness(pattern, separator_part=index)
192
+ if (
193
+ witness
194
+ and fnmatchcase(witness, pattern)
195
+ and not path_glob_matches(witness, pattern)
196
+ ):
197
+ effects.append(NARROWS)
198
+ witnesses[NARROWS] = witness
199
+ break
200
+ plain = _witness(pattern)
201
+ if case_insensitive_legacy and plain and plain.swapcase() != plain:
202
+ swapped = plain.swapcase()
203
+ if (
204
+ fnmatchcase(swapped.lower(), pattern.lower())
205
+ and not path_glob_matches(swapped, pattern)
206
+ ):
207
+ effects.append(CASE_NARROWS)
208
+ witnesses[CASE_NARROWS] = swapped
209
+ return PatternMigration(pattern, tuple(effects), witnesses)
210
+
211
+
212
+ def _string_entries(container: object, key: str) -> list[tuple[int, str]]:
213
+ if not isinstance(container, dict):
214
+ return []
215
+ value = container.get(key)
216
+ if not isinstance(value, list):
217
+ return []
218
+ return [
219
+ (index, entry) for index, entry in enumerate(value) if isinstance(entry, str)
220
+ ]
221
+
222
+
223
+ def _surface_globs(advisories: dict, section: str) -> list[tuple[str, str]]:
224
+ config = advisories.get(section)
225
+ surfaces = config.get("surfaces") if isinstance(config, dict) else None
226
+ if not isinstance(surfaces, list):
227
+ return []
228
+ return [
229
+ (f"workflowAdvisories.{section}.surfaces[{outer}].globs[{index}]", pattern)
230
+ for outer, surface in enumerate(surfaces)
231
+ for index, pattern in _string_entries(surface, "globs")
232
+ ]
233
+
234
+
235
+ def _profile_globs(document: object) -> list[tuple[str, str, bool]]:
236
+ """List every shipped consumer-profile glob call site's configured patterns."""
237
+ if not isinstance(document, dict):
238
+ return []
239
+ globs: list[tuple[str, str, bool]] = []
240
+ for index, pattern in _string_entries(
241
+ document.get("worktreeLifecycle"), "scratchPatterns",
242
+ ):
243
+ globs.append((f"worktreeLifecycle.scratchPatterns[{index}]", pattern, True))
244
+ for index, pattern in _string_entries(
245
+ document.get("wrapup"), "landingGeneratedArtifactPatterns",
246
+ ):
247
+ globs.append(
248
+ (f"wrapup.landingGeneratedArtifactPatterns[{index}]", pattern, True),
249
+ )
250
+ advisories = document.get("workflowAdvisories")
251
+ if isinstance(advisories, dict):
252
+ for index, pattern in _string_entries(advisories.get("baseline"), "sourceGlobs"):
253
+ globs.append(
254
+ (f"workflowAdvisories.baseline.sourceGlobs[{index}]", pattern, False),
255
+ )
256
+ for section in ("preRefactor", "stopChecks"):
257
+ globs.extend(
258
+ (location, pattern, False)
259
+ for location, pattern in _surface_globs(advisories, section)
260
+ )
261
+ return globs
262
+
263
+
264
+ def scan_profile(document: object) -> tuple[ProfileGlobFinding, ...]:
265
+ """Classify every consumer-profile glob against its own legacy matcher.
266
+
267
+ Worktree Lifecycle globs were always matched case-sensitively, while
268
+ Workflow Advisories globs went through the case-normalizing `fnmatch`, so
269
+ only the advisory keys can narrow on a case-insensitive host.
270
+ """
271
+ return tuple(
272
+ ProfileGlobFinding(
273
+ location,
274
+ pattern,
275
+ deletion_authority,
276
+ classify_pattern(
277
+ pattern, case_insensitive_legacy=not deletion_authority,
278
+ ),
279
+ )
280
+ for location, pattern, deletion_authority in _profile_globs(document)
281
+ )
282
+
283
+
284
+ def render_report(findings: tuple[ProfileGlobFinding, ...], source: str) -> str:
285
+ changed = [finding for finding in findings if finding.migration.effects]
286
+ lines = [
287
+ f"Profile glob dialect review — {source}",
288
+ f"Dialect: {DIALECT}",
289
+ f"Reviewed {len(findings)} consumer-profile glob(s); "
290
+ f"{len(changed)} change meaning.",
291
+ ]
292
+ for finding in changed:
293
+ authority = " [deletion authority]" if finding.deletion_authority else ""
294
+ lines.append("")
295
+ lines.append(f"{finding.location}{authority}: {finding.pattern}")
296
+ for effect in finding.migration.effects:
297
+ witness = finding.migration.witnesses[effect]
298
+ verb = "now also matches" if effect == WIDENS else "no longer matches"
299
+ lines.append(f" {effect}: {verb} {witness!r}")
300
+ if changed:
301
+ lines.append("")
302
+ lines.append(
303
+ "Review each line above and rewrite the pattern yourself. A widened "
304
+ "deletion-authority pattern expands what cleanup may remove; this "
305
+ "check never edits the profile and never migrates a pattern for you.",
306
+ )
307
+ return "\n".join(lines) + "\n"
308
+
309
+
310
+ def main(argv: list[str]) -> int:
311
+ arguments = [argument for argument in argv if argument != "--json"]
312
+ as_json = len(arguments) != len(argv)
313
+ if len(arguments) > 1:
314
+ print("usage: profile_globs.py [--json] [profile.json]", file=sys.stderr)
315
+ return 2
316
+ source = arguments[0] if arguments else DEFAULT_PROFILE
317
+ try:
318
+ document = json.loads(Path(source).read_text(encoding="utf-8"))
319
+ except (OSError, ValueError) as error:
320
+ print(f"cannot read consumer profile {source}: {error}", file=sys.stderr)
321
+ return 2
322
+ findings = scan_profile(document)
323
+ changed = [finding for finding in findings if finding.migration.effects]
324
+ if as_json:
325
+ print(json.dumps({
326
+ "source": source,
327
+ "dialect": DIALECT,
328
+ "reviewed": len(findings),
329
+ "changed": len(changed),
330
+ "findings": [
331
+ {
332
+ "location": finding.location,
333
+ "pattern": finding.pattern,
334
+ "deletionAuthority": finding.deletion_authority,
335
+ "effects": list(finding.migration.effects),
336
+ "witnesses": finding.migration.witnesses,
337
+ }
338
+ for finding in changed
339
+ ],
340
+ }, indent=2))
341
+ else:
342
+ print(render_report(findings, source), end="")
343
+ return 1 if changed else 0
344
+
345
+
346
+ if __name__ == "__main__":
347
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """Spec for `board_bootstrap.py` — offered board creation for /setup-workflow (#24).
3
+
4
+ Section D of `setup-workflow` used to refuse board creation outright. It now
5
+ OFFERS it, and the mechanical part (create → read back → validate → write the
6
+ profile) belongs to a deterministic helper rather than to prose.
7
+
8
+ Every path is exercised against a FAKE `gh` seam — this suite never touches the
9
+ live GitHub API and never creates a project:
10
+
11
+ * approval — the full sequence, ending in a profile `board_config` loads.
12
+ * missing scope — refused before the first write.
13
+ * decline — no invocation at all (the prose contract lives in
14
+ `test_skill_setup_workflow_seeds.py`).
15
+ * failure — a failed create, a missing field on readback, or a status
16
+ option a role maps to but the board lacks: no profile file.
17
+
18
+ Run: python3 scripts/test_board_bootstrap.py
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import json
24
+ import unittest
25
+ from contextlib import redirect_stderr, redirect_stdout
26
+ from pathlib import Path
27
+ from tempfile import TemporaryDirectory
28
+
29
+ import board_bootstrap as bb
30
+ import board_config
31
+
32
+ REPO = Path(__file__).resolve().parent.parent
33
+ SEED = REPO / ".claude/skills/setup-workflow/board-sync.md"
34
+
35
+ PROJECT_NODE = "PVT_fakeproject"
36
+ PROJECT_NUMBER = 7
37
+ OWNER = "octo"
38
+ REPO_SLUG = "octo/widgets"
39
+
40
+ AUTH_TEMPLATE = """github.com
41
+ - Active account: true
42
+ - Token scopes: {scopes}
43
+ """
44
+
45
+ WORKFLOW_FIELD_NAMES = ("Wave", "Cluster", "Spec-Path", "Plan-Path")
46
+
47
+
48
+ def seed_config(path=SEED):
49
+ return board_config.load_board_config(path)
50
+
51
+
52
+ def role_names(cfg=None):
53
+ return bb.status_option_names(cfg or seed_config())
54
+
55
+
56
+ def field_list_payload(status_options=None, workflow=WORKFLOW_FIELD_NAMES):
57
+ """A `gh project field-list --format json` payload in the shape the real CLI
58
+ emits (verified against a live board on 2026-07-26)."""
59
+ status_options = role_names() if status_options is None else status_options
60
+ fields = [{"id": "PVTF_fakeTitle", "name": "Title", "type": "ProjectV2Field"}]
61
+ fields.append({
62
+ "id": "PVTSSF_fakeStatus",
63
+ "name": "Status",
64
+ "type": "ProjectV2SingleSelectField",
65
+ "options": [{"id": f"opt{i}", "name": name} for i, name in enumerate(status_options)],
66
+ })
67
+ for name in workflow:
68
+ fields.append({"id": f"PVTF_fake{name.replace('-', '')}", "name": name,
69
+ "type": "ProjectV2Field"})
70
+ return {"fields": fields, "totalCount": len(fields)}
71
+
72
+
73
+ class FakeGh:
74
+ """Records every `gh` argv and answers from scripted payloads."""
75
+
76
+ def __init__(self, *, scopes="'gist', 'project', 'repo'", field_list=None,
77
+ fail_on_field=None, fail_on_create=False):
78
+ self.scopes = scopes
79
+ self.field_list = field_list_payload() if field_list is None else field_list
80
+ self.fail_on_field = fail_on_field
81
+ self.fail_on_create = fail_on_create
82
+ self.calls: list[list[str]] = []
83
+
84
+ @property
85
+ def write_calls(self):
86
+ return [c for c in self.calls if c[:2] in (["project", "create"],
87
+ ["project", "field-create"])]
88
+
89
+ def _flag(self, args, flag):
90
+ return args[args.index(flag) + 1]
91
+
92
+ def __call__(self, args):
93
+ args = list(args)
94
+ self.calls.append(args)
95
+ head = args[:2]
96
+ if head == ["auth", "status"]:
97
+ return AUTH_TEMPLATE.format(scopes=self.scopes)
98
+ if head == ["project", "create"]:
99
+ if self.fail_on_create:
100
+ raise bb.BootstrapError("`gh project create` failed: HTTP 500")
101
+ return json.dumps({"id": PROJECT_NODE, "number": PROJECT_NUMBER,
102
+ "title": self._flag(args, "--title"),
103
+ "url": f"https://github.com/users/{OWNER}/projects/{PROJECT_NUMBER}"})
104
+ if head == ["project", "field-create"]:
105
+ name = self._flag(args, "--name")
106
+ if name == self.fail_on_field:
107
+ raise bb.BootstrapError(f"`gh project field-create` failed: {name} rejected")
108
+ return json.dumps({"id": f"PVTF_new{name}", "name": name})
109
+ if head == ["project", "field-list"]:
110
+ return json.dumps(self.field_list)
111
+ raise AssertionError(f"unexpected gh call: {' '.join(args)}")
112
+
113
+
114
+ def run_create(fake, out_path, *, seed=SEED, extra=()):
115
+ """Invoke the CLI with `fake` patched in; return (exit_code, stdout, stderr)."""
116
+ argv = ["create", "--owner", OWNER, "--repo", REPO_SLUG, "--title", "Widgets Workflow",
117
+ "--seed", str(seed), "--out", str(out_path), *extra]
118
+ original = bb._gh
119
+ bb._gh = fake
120
+ out, err = io.StringIO(), io.StringIO()
121
+ try:
122
+ with redirect_stdout(out), redirect_stderr(err):
123
+ code = bb.main(argv)
124
+ finally:
125
+ bb._gh = original
126
+ return code, out.getvalue(), err.getvalue()
127
+
128
+
129
+ class ScopePreflight(unittest.TestCase):
130
+ def test_project_scope_present_is_no_finding(self):
131
+ self.assertEqual(bb.missing_scopes(AUTH_TEMPLATE.format(scopes="'repo', 'project'")), [])
132
+
133
+ def test_project_scope_absent_is_reported(self):
134
+ self.assertEqual(bb.missing_scopes(AUTH_TEMPLATE.format(scopes="'repo', 'gist'")),
135
+ ["project"])
136
+
137
+ def test_missing_scope_refuses_before_any_write(self):
138
+ fake = FakeGh(scopes="'repo', 'gist'")
139
+ with TemporaryDirectory() as tmp:
140
+ out = Path(tmp) / "board-sync.md"
141
+ code, _, err = run_create(fake, out)
142
+ self.assertEqual(code, bb.EXIT_MISSING_SCOPE)
143
+ self.assertEqual(fake.write_calls, [], "no board may be created without the scope")
144
+ self.assertIn("gh auth refresh -s project,read:project", err)
145
+ self.assertFalse(out.exists(), "a scope failure must not write a profile")
146
+
147
+
148
+ class StatusOptionsComeFromTheProfile(unittest.TestCase):
149
+ def test_option_names_are_the_seeded_role_names_in_role_order(self):
150
+ cfg = seed_config()
151
+ roles = board_config.status_roles(cfg)
152
+ self.assertEqual(role_names(cfg),
153
+ [roles[key] for key in board_config.STATUS_ROLE_KEYS if key in roles])
154
+
155
+ def test_a_non_english_roles_map_yields_its_own_option_names(self):
156
+ cfg = seed_config()
157
+ cfg["fields"]["status"]["roles"] = {"spec": "Spécification", "inProgress": "En cours",
158
+ "done": "Terminé"}
159
+ self.assertEqual(bb.status_option_names(cfg), ["Spécification", "En cours", "Terminé"])
160
+
161
+ def test_an_empty_roles_map_is_a_hard_error_not_an_english_default(self):
162
+ cfg = seed_config()
163
+ cfg["fields"]["status"]["roles"] = {}
164
+ with self.assertRaises(bb.BootstrapError):
165
+ bb.status_option_names(cfg)
166
+
167
+ def test_a_comma_in_an_option_name_is_refused(self):
168
+ cfg = seed_config()
169
+ cfg["fields"]["status"]["roles"] = {"spec": "Spec, draft"}
170
+ with self.assertRaises(bb.BootstrapError):
171
+ bb.status_option_names(cfg)
172
+
173
+
174
+ class ApprovalPath(unittest.TestCase):
175
+ def setUp(self):
176
+ self.tmp = TemporaryDirectory()
177
+ self.addCleanup(self.tmp.cleanup)
178
+ self.out = Path(self.tmp.name) / "board-sync.md"
179
+ self.fake = FakeGh()
180
+ self.code, self.stdout, self.stderr = run_create(self.fake, self.out)
181
+
182
+ def test_exit_is_clean(self):
183
+ self.assertEqual(self.code, 0, self.stderr)
184
+
185
+ def test_the_sequence_is_create_then_fields_then_readback(self):
186
+ shape = [tuple(c[:2]) for c in self.fake.calls]
187
+ self.assertEqual(shape, [
188
+ ("auth", "status"),
189
+ ("project", "create"),
190
+ *[("project", "field-create")] * (1 + len(WORKFLOW_FIELD_NAMES)),
191
+ ("project", "field-list"),
192
+ ])
193
+
194
+ def test_status_is_created_with_its_options_in_one_call(self):
195
+ call = next(c for c in self.fake.calls
196
+ if c[:2] == ["project", "field-create"] and "Status" in c)
197
+ self.assertIn("--single-select-options", call)
198
+ self.assertEqual(call[call.index("--single-select-options") + 1],
199
+ ",".join(role_names()))
200
+ self.assertEqual(call[call.index("--data-type") + 1], "SINGLE_SELECT")
201
+
202
+ def test_every_workflow_field_is_created(self):
203
+ created = {c[c.index("--name") + 1] for c in self.fake.calls
204
+ if c[:2] == ["project", "field-create"]}
205
+ self.assertEqual(created, {"Status", *WORKFLOW_FIELD_NAMES})
206
+
207
+ def test_the_written_file_carries_the_filled_sentinel(self):
208
+ first = self.out.read_text(encoding="utf-8").splitlines()[0]
209
+ self.assertEqual(first, "<!-- setup-workflow: state=filled; mode=github-projects-v2 -->")
210
+
211
+ def test_board_config_loads_the_written_profile(self):
212
+ cfg = board_config.load_board_config(self.out) # raises ConfigError on any gap
213
+ self.assertEqual(cfg["repo"], REPO_SLUG)
214
+ self.assertEqual(cfg["project"], {"number": PROJECT_NUMBER, "owner": OWNER,
215
+ "nodeId": PROJECT_NODE})
216
+
217
+ def test_ids_come_from_the_readback_not_from_the_create_calls(self):
218
+ cfg = board_config.load_board_config(self.out)
219
+ by_name = {f["name"]: f for f in self.fake.field_list["fields"]}
220
+ self.assertEqual(cfg["fields"]["status"]["id"], by_name["Status"]["id"])
221
+ self.assertEqual(cfg["fields"]["wave"], by_name["Wave"]["id"])
222
+ self.assertEqual(cfg["fields"]["cluster"], by_name["Cluster"]["id"])
223
+ self.assertEqual(cfg["fields"]["specPath"], by_name["Spec-Path"]["id"])
224
+ self.assertEqual(cfg["fields"]["planPath"], by_name["Plan-Path"]["id"])
225
+ self.assertEqual(cfg["fields"]["status"]["options"],
226
+ {o["name"]: o["id"] for o in by_name["Status"]["options"]})
227
+
228
+ def test_every_status_role_resolves_to_a_real_option(self):
229
+ cfg = board_config.load_board_config(self.out)
230
+ options = cfg["fields"]["status"]["options"]
231
+ roles = board_config.status_roles(cfg)
232
+ self.assertTrue(roles)
233
+ for role, name in roles.items():
234
+ self.assertIn(name, options, f"role {role} maps to an option the board lacks")
235
+
236
+ def test_no_placeholder_survives_in_the_profile(self):
237
+ raw = self.out.read_text(encoding="utf-8")
238
+ block = raw.split("<!-- board-sync:profile -->", 1)[1].split("```")[1]
239
+ self.assertNotIn("<fill", block)
240
+ self.assertNotIn("<owner>", block)
241
+
242
+ def test_the_optional_phase_placeholder_is_not_claimed(self):
243
+ cfg = board_config.load_board_config(self.out)
244
+ self.assertIsNone(board_config.phase_field_id(cfg),
245
+ "an uncreated Phase field must not appear in the profile")
246
+
247
+ def test_the_seed_conventions_survive(self):
248
+ cfg = board_config.load_board_config(self.out)
249
+ seed = seed_config()
250
+ for key in ("labels", "branchPrefixes", "prMarkers", "headings", "titles", "wrapup"):
251
+ self.assertEqual(cfg.get(key), seed.get(key), key)
252
+
253
+ def test_the_documentation_body_survives(self):
254
+ self.assertIn("## Board profile", self.out.read_text(encoding="utf-8"))
255
+
256
+
257
+ class FailurePathsWriteNoProfile(unittest.TestCase):
258
+ def _run(self, fake, **kwargs):
259
+ with TemporaryDirectory() as tmp:
260
+ out = Path(tmp) / "board-sync.md"
261
+ code, _, err = run_create(fake, out, **kwargs)
262
+ return code, err, out.exists()
263
+
264
+ def test_failed_project_create_writes_nothing(self):
265
+ code, err, exists = self._run(FakeGh(fail_on_create=True))
266
+ self.assertEqual(code, bb.EXIT_FAILURE)
267
+ self.assertFalse(exists)
268
+ self.assertIn("gh project create", err)
269
+
270
+ def test_failed_field_create_writes_nothing_and_names_the_project(self):
271
+ fake = FakeGh(fail_on_field="Spec-Path")
272
+ code, err, exists = self._run(fake)
273
+ self.assertEqual(code, bb.EXIT_FAILURE)
274
+ self.assertFalse(exists, "a partial board must never produce a profile")
275
+ self.assertIn(str(PROJECT_NUMBER), err, "the half-created project must be named")
276
+
277
+ def test_a_field_missing_on_readback_fails_validation(self):
278
+ fake = FakeGh(field_list=field_list_payload(workflow=("Wave", "Cluster", "Spec-Path")))
279
+ code, err, exists = self._run(fake)
280
+ self.assertEqual(code, bb.EXIT_FAILURE)
281
+ self.assertFalse(exists)
282
+ self.assertIn("Plan-Path", err)
283
+
284
+ def test_a_status_option_missing_on_readback_fails_validation(self):
285
+ fake = FakeGh(field_list=field_list_payload(status_options=role_names()[:-1]))
286
+ code, err, exists = self._run(fake)
287
+ self.assertEqual(code, bb.EXIT_FAILURE)
288
+ self.assertFalse(exists)
289
+ self.assertIn("role", err)
290
+
291
+ def test_an_absent_status_field_fails_validation(self):
292
+ payload = field_list_payload()
293
+ payload["fields"] = [f for f in payload["fields"] if f["name"] != "Status"]
294
+ code, err, exists = self._run(FakeGh(field_list=payload))
295
+ self.assertEqual(code, bb.EXIT_FAILURE)
296
+ self.assertFalse(exists)
297
+ self.assertIn("Status", err)
298
+
299
+
300
+ class DestinationGuard(unittest.TestCase):
301
+ def test_decision_table(self):
302
+ self.assertEqual(bb.destination_action(None), "create")
303
+ self.assertEqual(bb.destination_action(
304
+ "<!-- setup-workflow: state=stub; mode=github-projects-v2 -->"), "fill")
305
+ self.assertEqual(bb.destination_action(
306
+ "<!-- setup-workflow: state=filled; mode=github-projects-v2 -->"), "refuse")
307
+ self.assertEqual(bb.destination_action(
308
+ "<!-- setup-workflow: state=not-applicable; mode=none -->"), "refuse")
309
+ self.assertEqual(bb.destination_action("# Board sync"), "refuse")
310
+
311
+ def test_a_filled_profile_is_never_overwritten_and_nothing_is_created(self):
312
+ fake = FakeGh()
313
+ with TemporaryDirectory() as tmp:
314
+ out = Path(tmp) / "board-sync.md"
315
+ out.write_text("<!-- setup-workflow: state=filled; mode=github-projects-v2 -->\n# x\n",
316
+ encoding="utf-8")
317
+ code, _, err = run_create(fake, out)
318
+ self.assertEqual(code, bb.EXIT_REFUSED)
319
+ self.assertEqual(fake.calls, [], "the destination is checked before any gh call")
320
+ self.assertIn("state=filled", err)
321
+ self.assertTrue(out.read_text(encoding="utf-8").endswith("# x\n"))
322
+
323
+ def test_a_stub_destination_is_filled(self):
324
+ fake = FakeGh()
325
+ with TemporaryDirectory() as tmp:
326
+ out = Path(tmp) / "board-sync.md"
327
+ out.write_text("<!-- setup-workflow: state=stub; mode=github-projects-v2 -->\nold\n",
328
+ encoding="utf-8")
329
+ code, _, err = run_create(fake, out)
330
+ self.assertEqual(code, 0, err)
331
+ self.assertEqual(board_config.load_board_config(out)["repo"], REPO_SLUG)
332
+
333
+
334
+ class DryRun(unittest.TestCase):
335
+ def test_dry_run_creates_nothing(self):
336
+ fake = FakeGh()
337
+ with TemporaryDirectory() as tmp:
338
+ out = Path(tmp) / "board-sync.md"
339
+ code, stdout, err = run_create(fake, out, extra=("--dry-run",))
340
+ self.assertEqual(code, 0, err)
341
+ self.assertEqual(fake.write_calls, [])
342
+ self.assertFalse(out.exists())
343
+ self.assertIn("gh project create", stdout)
344
+ self.assertIn(",".join(role_names()), stdout)
345
+
346
+
347
+ if __name__ == "__main__":
348
+ unittest.main(verbosity=2)