@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,367 @@
1
+ #!/usr/bin/env python3
2
+ """board_bootstrap.py — provision a GitHub-Projects board for /setup-workflow.
3
+
4
+ `setup-workflow` Section D used to refuse creation and hand out manual
5
+ instructions. It now OFFERS creation, and this helper owns the mechanical part
6
+ so the outcome is deterministic rather than prose-driven:
7
+
8
+ gh project create # the project shell
9
+ gh project field-create … Status # single-select WITH its options
10
+ gh project field-create … Wave/Cluster/Spec-Path/Plan-Path
11
+ gh project field-list # discover the opaque ids
12
+
13
+ Four properties this file exists to guarantee:
14
+
15
+ * Nothing is created before the destination is writable and `gh auth status`
16
+ proves the write scope — a missing scope can never half-create a board.
17
+ * The profile is written from the READ-BACK, never from what the create calls
18
+ were asked to do. A field that did not survive creation cannot appear in it.
19
+ * A failure anywhere (create, readback, validation) writes NO profile file.
20
+ The caller falls back to the retryable stub path with the board it can see.
21
+ * The Status option NAMES come from the seed profile's `fields.status.roles`,
22
+ never from a literal in this file — a board in another language provisions
23
+ its own stage names.
24
+
25
+ The offer/decline decision itself is NOT here: minting a project is an outward
26
+ action, so the user gate stays with the skill and this helper only runs after an
27
+ explicit yes.
28
+
29
+ Usage:
30
+ python3 scripts/board_bootstrap.py preflight [--json]
31
+ python3 scripts/board_bootstrap.py create --owner <owner> --repo <owner>/<repo> \\
32
+ --title "<title>" --seed <seeded board-sync.md> --out docs/agents/board-sync.md \\
33
+ [--dry-run]
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import copy
39
+ import json
40
+ import re
41
+ import shlex
42
+ import subprocess
43
+ import sys
44
+ from pathlib import Path
45
+
46
+ import board_config
47
+
48
+ # Field NAMES for a NEW board. These are creation-time conventions (the profile
49
+ # stores ids under fixed keys and never carries a field name), documented in the
50
+ # seeded board-sync.md field table. Discovery matches the readback by them.
51
+ STATUS_FIELD_NAME = "Status"
52
+ WORKFLOW_FIELDS = (
53
+ ("wave", "Wave", "NUMBER"),
54
+ ("cluster", "Cluster", "TEXT"),
55
+ ("specPath", "Spec-Path", "TEXT"),
56
+ ("planPath", "Plan-Path", "TEXT"),
57
+ )
58
+
59
+ # `gh project create` + `field-create` need the write scope; the read scope is
60
+ # what every later board read uses, so the remedy asks for both at once.
61
+ REQUIRED_SCOPES = ("project",)
62
+ SCOPE_REMEDY = "gh auth refresh -s project,read:project"
63
+
64
+ SENTINEL = "<!-- setup-workflow: state=filled; mode=github-projects-v2 -->"
65
+ SENTINEL_RE = re.compile(
66
+ r"^<!--\s*setup-workflow:\s*state=(stub|filled|not-applicable)"
67
+ r"(?:;\s*mode=(github-projects-v2|none))?\s*-->\s*$")
68
+ PROFILE_MARKER_RE = re.compile(r"<!--\s*board-sync:profile\s*-->")
69
+ JSON_FENCE_RE = re.compile(r"(```json\s*\n)(.*?)(\n```)", re.DOTALL)
70
+ PLACEHOLDER_RE = re.compile(r"<[^<>\n]*>")
71
+
72
+ EXIT_FAILURE = 1
73
+ EXIT_REFUSED = 2
74
+ EXIT_MISSING_SCOPE = 3
75
+
76
+
77
+ class BootstrapError(RuntimeError):
78
+ """Any condition that must abort before or instead of writing a profile."""
79
+
80
+
81
+ # --- the gh seam (tests replace `_gh`) ---------------------------------------
82
+ def _gh(args: list[str]) -> str:
83
+ proc = subprocess.run(["gh", *args], capture_output=True, text=True)
84
+ if proc.returncode != 0:
85
+ detail = (proc.stderr or proc.stdout).strip()
86
+ raise BootstrapError(f"`gh {' '.join(args)}` failed: {detail}")
87
+ return proc.stdout
88
+
89
+
90
+ def _gh_json(args: list[str]):
91
+ raw = _gh(args)
92
+ try:
93
+ return json.loads(raw)
94
+ except json.JSONDecodeError as exc:
95
+ raise BootstrapError(f"`gh {' '.join(args)}` did not return JSON: {exc}") from exc
96
+
97
+
98
+ # --- pure decisions ----------------------------------------------------------
99
+ def missing_scopes(auth_status_text: str, required=REQUIRED_SCOPES) -> list[str]:
100
+ """The required OAuth scopes `gh auth status` does NOT report."""
101
+ granted: set[str] = set()
102
+ for line in auth_status_text.splitlines():
103
+ if "Token scopes:" in line:
104
+ granted |= {part.strip().strip("'\"")
105
+ for part in line.split(":", 1)[1].split(",")}
106
+ return [scope for scope in required if scope not in granted]
107
+
108
+
109
+ def status_option_names(cfg: dict) -> list[str]:
110
+ """This board's Status option names, read from the seed profile's role map in
111
+ role order (extra consumer roles keep their own order at the end)."""
112
+ roles = board_config.status_roles(cfg)
113
+ ordered = list(board_config.STATUS_ROLE_KEYS)
114
+ ordered += [key for key in roles if key not in ordered]
115
+ names: list[str] = []
116
+ for key in ordered:
117
+ name = roles.get(key)
118
+ if name and name not in names:
119
+ names.append(name)
120
+ if not names:
121
+ raise BootstrapError(
122
+ "the seed profile carries no `fields.status.roles` — fill the role map "
123
+ "with this board's stage names before creating the board.")
124
+ commas = [name for name in names if "," in name]
125
+ if commas:
126
+ raise BootstrapError(
127
+ f"status option name(s) {commas} contain a comma; "
128
+ "`gh project field-create --single-select-options` is comma-separated. "
129
+ "Rename the role value, or create the Status field by hand.")
130
+ return names
131
+
132
+
133
+ def destination_action(first_line: str | None) -> str:
134
+ """`create` / `fill` / `refuse` for the destination profile, per the
135
+ setup-workflow idempotency contract: missing or `state=stub` may be written,
136
+ everything else (filled, not-applicable, legacy without sentinel) is owned by
137
+ the consumer and never overwritten."""
138
+ if first_line is None:
139
+ return "create"
140
+ match = SENTINEL_RE.match(first_line.strip())
141
+ if match:
142
+ return "fill" if match.group(1) == "stub" else "refuse"
143
+ return "refuse"
144
+
145
+
146
+ def index_fields(field_list) -> dict:
147
+ fields = field_list.get("fields") if isinstance(field_list, dict) else field_list
148
+ return {f["name"]: f for f in (fields or [])
149
+ if isinstance(f, dict) and f.get("name")}
150
+
151
+
152
+ def build_profile(seed_cfg: dict, *, repo: str, owner: str, number, node_id: str,
153
+ field_list) -> dict:
154
+ """The consumer profile assembled from the seed's conventions plus the ids
155
+ read back off the board. Every id is best-effort: what is absent stays
156
+ absent so `validate_profile` can report it instead of a claim being made."""
157
+ by_name = index_fields(field_list)
158
+ profile = copy.deepcopy(seed_cfg)
159
+ profile["repo"] = repo
160
+ profile["project"] = {"number": number, "owner": owner, "nodeId": node_id}
161
+
162
+ status_field = by_name.get(STATUS_FIELD_NAME)
163
+ status = {"options": {}, "roles": copy.deepcopy(board_config.status_roles(seed_cfg))}
164
+ if status_field:
165
+ status["id"] = status_field.get("id")
166
+ status["options"] = {opt["name"]: opt["id"]
167
+ for opt in (status_field.get("options") or [])
168
+ if opt.get("name") and opt.get("id")}
169
+ # A fresh profile carries only what was created and read back — the optional
170
+ # `fields.phase` block stays out (the Program route is opt-in and manual).
171
+ fields = {"status": status}
172
+ for key, name, _ in WORKFLOW_FIELDS:
173
+ field = by_name.get(name)
174
+ if field and field.get("id"):
175
+ fields[key] = field["id"]
176
+ profile["fields"] = fields
177
+ return profile
178
+
179
+
180
+ def validate_profile(profile: dict) -> list[str]:
181
+ """Every reason this profile must not be written, as plain sentences."""
182
+ problems: list[str] = []
183
+ fields = profile.get("fields") or {}
184
+ status = fields.get("status") or {}
185
+ options = status.get("options") or {}
186
+ if not status.get("id"):
187
+ problems.append(f"the readback carries no `{STATUS_FIELD_NAME}` field")
188
+ if not options:
189
+ problems.append(f"the readback carries no `{STATUS_FIELD_NAME}` options")
190
+ for role, name in (status.get("roles") or {}).items():
191
+ if name not in options:
192
+ problems.append(
193
+ f"status role `{role}` maps to option {name!r}, which the board does not have")
194
+ for key, name, _ in WORKFLOW_FIELDS:
195
+ if not fields.get(key):
196
+ problems.append(f"the readback carries no `{name}` field")
197
+ project = profile.get("project") or {}
198
+ if not project.get("nodeId"):
199
+ problems.append("the project node id is missing")
200
+ if not project.get("number"):
201
+ problems.append("the project number is missing")
202
+ if not profile.get("repo"):
203
+ problems.append("the repository is missing")
204
+ leftovers = sorted(set(PLACEHOLDER_RE.findall(
205
+ json.dumps(profile, ensure_ascii=False))))
206
+ if leftovers:
207
+ problems.append(f"unresolved seed placeholder(s): {', '.join(leftovers)}")
208
+ return problems
209
+
210
+
211
+ def render_document(seed_text: str, profile: dict) -> str:
212
+ """The seeded board-sync.md with the filled sentinel and the profile block
213
+ replaced — the documentation body around it survives byte-for-byte."""
214
+ body = seed_text
215
+ first, _, rest = body.partition("\n")
216
+ if SENTINEL_RE.match(first.strip()):
217
+ body = rest
218
+ marker = PROFILE_MARKER_RE.search(body)
219
+ if not marker:
220
+ raise BootstrapError("the seed carries no `<!-- board-sync:profile -->` marker")
221
+ fence = JSON_FENCE_RE.search(body, marker.end())
222
+ if not fence:
223
+ raise BootstrapError("the seed's profile marker is not followed by a ```json block")
224
+ rendered = json.dumps(profile, indent=2, ensure_ascii=False)
225
+ return SENTINEL + "\n" + body[:fence.start(2)] + rendered + body[fence.end(2):]
226
+
227
+
228
+ # --- the sequence ------------------------------------------------------------
229
+ def creation_plan(owner: str, title: str, status_options: list[str]) -> list[list[str]]:
230
+ """The `gh` argv sequence, project-number placeholder `{number}` included —
231
+ also what `--dry-run` prints."""
232
+ plan = [["project", "create", "--owner", owner, "--title", title, "--format", "json"]]
233
+ plan.append(["project", "field-create", "{number}", "--owner", owner,
234
+ "--name", STATUS_FIELD_NAME, "--data-type", "SINGLE_SELECT",
235
+ "--single-select-options", ",".join(status_options), "--format", "json"])
236
+ for _, name, data_type in WORKFLOW_FIELDS:
237
+ plan.append(["project", "field-create", "{number}", "--owner", owner,
238
+ "--name", name, "--data-type", data_type, "--format", "json"])
239
+ plan.append(["project", "field-list", "{number}", "--owner", owner, "--format", "json"])
240
+ return plan
241
+
242
+
243
+ def create_board(*, owner: str, repo: str, title: str, seed_cfg: dict) -> dict:
244
+ """Create the board, read it back, and return the validated profile. Raises
245
+ `BootstrapError` — with the created project named — on any failure."""
246
+ options = status_option_names(seed_cfg)
247
+ created = _gh_json(["project", "create", "--owner", owner, "--title", title,
248
+ "--format", "json"])
249
+ number, node_id = created.get("number"), created.get("id")
250
+ if not number or not node_id:
251
+ raise BootstrapError(
252
+ f"`gh project create` returned no project number/id: {created!r}")
253
+ where = (f"project #{number} ({created.get('url') or owner}) was created; "
254
+ "finish or delete it before retrying")
255
+ try:
256
+ _gh(["project", "field-create", str(number), "--owner", owner,
257
+ "--name", STATUS_FIELD_NAME, "--data-type", "SINGLE_SELECT",
258
+ "--single-select-options", ",".join(options), "--format", "json"])
259
+ for _, name, data_type in WORKFLOW_FIELDS:
260
+ _gh(["project", "field-create", str(number), "--owner", owner,
261
+ "--name", name, "--data-type", data_type, "--format", "json"])
262
+ field_list = _gh_json(["project", "field-list", str(number), "--owner", owner,
263
+ "--format", "json"])
264
+ except BootstrapError as exc:
265
+ raise BootstrapError(f"{exc} — {where}") from exc
266
+ profile = build_profile(seed_cfg, repo=repo, owner=owner, number=number,
267
+ node_id=node_id, field_list=field_list)
268
+ problems = validate_profile(profile)
269
+ if problems:
270
+ raise BootstrapError(
271
+ "the board readback does not satisfy the workflow profile:\n - "
272
+ + "\n - ".join(problems) + f"\n{where}")
273
+ return profile
274
+
275
+
276
+ def write_profile(out: Path, seed_text: str, profile: dict) -> None:
277
+ """Write the document, then re-read it through the real loader. A profile the
278
+ loader rejects is removed again — never left behind as a filled claim."""
279
+ out.parent.mkdir(parents=True, exist_ok=True)
280
+ out.write_text(render_document(seed_text, profile), encoding="utf-8")
281
+ try:
282
+ board_config.load_board_config(out)
283
+ except board_config.ConfigError as exc:
284
+ out.unlink(missing_ok=True)
285
+ raise BootstrapError(f"the written profile does not load: {exc}") from exc
286
+
287
+
288
+ # --- CLI ---------------------------------------------------------------------
289
+ def _first_line(path: Path):
290
+ if not path.exists():
291
+ return None
292
+ text = path.read_text(encoding="utf-8")
293
+ return text.splitlines()[0] if text.strip() else None
294
+
295
+
296
+ def _preflight(args) -> int:
297
+ missing = missing_scopes(_gh(["auth", "status"]))
298
+ if args.json:
299
+ print(json.dumps({"missingScopes": missing, "remedy": SCOPE_REMEDY}))
300
+ elif missing:
301
+ print(f"missing scope(s): {', '.join(missing)} — run: {SCOPE_REMEDY}")
302
+ else:
303
+ print("scopes ok")
304
+ return EXIT_MISSING_SCOPE if missing else 0
305
+
306
+
307
+ def _create(args) -> int:
308
+ out = Path(args.out)
309
+ first_line = _first_line(out)
310
+ action = destination_action(first_line)
311
+ if action == "refuse":
312
+ observed = SENTINEL_RE.match((first_line or "").strip())
313
+ state = f"state={observed.group(1)}" if observed else "no setup-workflow sentinel"
314
+ print(f"{out} is consumer-owned ({state}) — refusing to overwrite it. "
315
+ "Only a missing file or `state=stub` may be filled.", file=sys.stderr)
316
+ return EXIT_REFUSED
317
+
318
+ seed_text = Path(args.seed).read_text(encoding="utf-8")
319
+ seed_cfg = board_config.load_board_config(args.seed)
320
+
321
+ missing = missing_scopes(_gh(["auth", "status"]))
322
+ if missing:
323
+ print(f"`gh` is missing the {', '.join(missing)} scope — no board was created. "
324
+ f"Run: {SCOPE_REMEDY}", file=sys.stderr)
325
+ return EXIT_MISSING_SCOPE
326
+
327
+ if args.dry_run:
328
+ for call in creation_plan(args.owner, args.title, status_option_names(seed_cfg)):
329
+ print(shlex.join(["gh", *call])) # copy-pasteable, not just readable
330
+ print(f"destination: {out} ({action})")
331
+ return 0
332
+
333
+ profile = create_board(owner=args.owner, repo=args.repo, title=args.title,
334
+ seed_cfg=seed_cfg)
335
+ write_profile(out, seed_text, profile)
336
+ print(f"board created: project #{profile['project']['number']} "
337
+ f"({profile['project']['owner']}) — profile written to {out}")
338
+ return 0
339
+
340
+
341
+ def main(argv=None) -> int:
342
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
343
+ sub = parser.add_subparsers(dest="command", required=True)
344
+
345
+ pre = sub.add_parser("preflight", help="check the gh scopes board creation needs")
346
+ pre.add_argument("--json", action="store_true")
347
+ pre.set_defaults(func=_preflight)
348
+
349
+ create = sub.add_parser("create", help="create the board and write the filled profile")
350
+ create.add_argument("--owner", required=True)
351
+ create.add_argument("--repo", required=True, help="owner/repo of the consumer repository")
352
+ create.add_argument("--title", required=True)
353
+ create.add_argument("--seed", required=True, help="the seeded board-sync.md template")
354
+ create.add_argument("--out", required=True, help="destination, e.g. docs/agents/board-sync.md")
355
+ create.add_argument("--dry-run", action="store_true")
356
+ create.set_defaults(func=_create)
357
+
358
+ args = parser.parse_args(argv)
359
+ try:
360
+ return args.func(args)
361
+ except (BootstrapError, board_config.ConfigError) as exc:
362
+ print(str(exc), file=sys.stderr)
363
+ return EXIT_FAILURE
364
+
365
+
366
+ if __name__ == "__main__":
367
+ sys.exit(main())
@@ -42,17 +42,30 @@ export async function orchestrateUpdatePullRequest(options) {
42
42
  return { status: 'created', branch };
43
43
  }
44
44
 
45
- function pullRequestBody(stdout = '') {
46
- const labels = ['newly available:', 'newly degraded:', 'newly blocked:', 'still unresolved:'];
47
- const clean = stdout.replaceAll(/\x1b\[[0-9;]*m/g, '');
48
- const summary = clean.split('\n')
49
- .map((line) => line.trim())
45
+ const AVAILABILITY_LABELS = [
46
+ 'newly available:', 'newly degraded:', 'newly blocked:', 'still unresolved:',
47
+ ];
48
+ const MIGRATION_LABELS = ['required migration:'];
49
+
50
+ function summarize(lines, labels) {
51
+ return lines
50
52
  .filter((line) => labels.some((label) => line.includes(label)))
51
53
  .map((line) => line.slice(Math.min(...labels.map((label) => {
52
54
  const index = line.indexOf(label);
53
55
  return index < 0 ? line.length : index;
54
- }))));
55
- return [UPDATE_BODY, '## Availability', summary.join('\n') || 'No readiness availability changes reported.'].join('\n\n');
56
+ }))))
57
+ .join('\n');
58
+ }
59
+
60
+ function pullRequestBody(stdout = '') {
61
+ const lines = stdout.replaceAll(/\x1b\[[0-9;]*m/g, '').split('\n').map((line) => line.trim());
62
+ return [
63
+ UPDATE_BODY,
64
+ '## Availability',
65
+ summarize(lines, AVAILABILITY_LABELS) || 'No readiness availability changes reported.',
66
+ '## Required migrations',
67
+ summarize(lines, MIGRATION_LABELS) || 'No required consumer migrations reported.',
68
+ ].join('\n\n');
56
69
  }
57
70
 
58
71
  export function createSystemAdapters({
@@ -51,6 +51,35 @@ test('automated update pull requests carry the behavior availability summary', a
51
51
  assert.match(created.body, /never merged automatically/);
52
52
  });
53
53
 
54
+ test('automated update pull requests carry the required consumer migrations', async () => {
55
+ const h = harness({ update: {
56
+ exitCode: 0,
57
+ stdout: [
58
+ 'newly available: none',
59
+ 'required migration: wrapup-landing-artifact-policy · setup-workflow · '
60
+ + 'docs/agents/workflow-capabilities.json · wrapup.landingGeneratedArtifactPatterns',
61
+ ].join('\n'),
62
+ stderr: '',
63
+ } });
64
+
65
+ await orchestrateUpdatePullRequest(h.options);
66
+
67
+ const created = h.calls.find((call) => Array.isArray(call) && call[0] === 'create')[1];
68
+ assert.match(created.body, /## Required migrations/);
69
+ assert.match(created.body, /required migration: wrapup-landing-artifact-policy/);
70
+ assert.match(created.body, /docs\/agents\/workflow-capabilities\.json/);
71
+ assert.match(created.body, /wrapup\.landingGeneratedArtifactPatterns/);
72
+ });
73
+
74
+ test('a pull request without pending migrations says so explicitly', async () => {
75
+ const h = harness({ update: { exitCode: 0, stdout: 'newly available: none', stderr: '' } });
76
+
77
+ await orchestrateUpdatePullRequest(h.options);
78
+
79
+ const created = h.calls.find((call) => Array.isArray(call) && call[0] === 'create')[1];
80
+ assert.match(created.body, /## Required migrations\n\nNo required consumer migrations reported\./);
81
+ });
82
+
54
83
  test('a conflict produces a structured report without touching the stable branch', async () => {
55
84
  const h = harness({ update: { exitCode: 2, stdout: 'conflicts: 1', stderr: '' } });
56
85
  const report = await orchestrateUpdatePullRequest(h.options);