@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
@@ -13,8 +13,8 @@ NOT a board-wide scan, NOT a "grill-exit" event (no clean tool hook for that).
13
13
  Accepted gap: Bash redirect / tee / cp into `.handoff/` is not covered — the
14
14
  threat model is "skill/agent forgot", not an adversary; handoff writes via Write.
15
15
 
16
- Mechanism: self-filters to `.handoff/*.md`; extracts the issue (content anchor
17
- first, filename fallback); delegates graph coherence to
16
+ Mechanism: self-filters to `.handoff/*.md`; extracts the issue (an own-repository
17
+ content anchor first, then the filename); delegates graph coherence to
18
18
  scripts/execute-ready-check.py (`--mode handoff`) and census state/fingerprint
19
19
  evaluation to `scripts/census/index.mjs`. Deny = exit 2 + stderr (house pattern:
20
20
  enforce-worktree.py, block-secrets.py). A deliberate
@@ -39,8 +39,18 @@ from _hook_utils import log
39
39
  HOOK_NAME = "drift-guard"
40
40
  HANDLED_TOOLS = {"Write", "Edit", "MultiEdit"}
41
41
  HANDOFF_PATH_RE = re.compile(r"/\.handoff/[^/]*\.md$")
42
- ISSUE_ANCHOR_RE = re.compile(r"/issues/(\d+)") # content-first: [#n](…/issues/n)
43
- FILENAME_ISSUE_RE = re.compile(r"(\d+)\.md$") # fallback: <date>-<n>.md
42
+ ISSUE_ANCHOR_RE = re.compile(r"/issues/(\d+)") # any repo: [#n](…/issues/n)
43
+ FILENAME_ISSUE_RE = re.compile(r"(\d+)\.md$") # skill-controlled: <date>-<n>.md
44
+ # Remote URL → host/owner/repo. Covers scp-style (`git@host:owner/repo.git`),
45
+ # https and ssh:// forms; deeper namespaces and local paths deliberately do not
46
+ # match — an unparsable remote falls back to the filename anchor.
47
+ REMOTE_URL_RE = re.compile(
48
+ r"^(?:[a-z][a-z0-9+.-]*://)?(?:[^@/\s]+@)?(?P<host>[^/:\s]+)[/:]"
49
+ r"(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?/?$",
50
+ re.IGNORECASE,
51
+ )
52
+ # Bounded diagnostics: a large drift reports its head plus a remainder counter.
53
+ CENSUS_DELTA_LIMIT = 10
44
54
  # Override must be issue-scoped, rev-scoped, reasoned, by-user (Codex R1 — not the cheap `known`).
45
55
  GUARD_ACK_RE = re.compile(r"<!--\s*guard-ack:\s*#?\d+\s+r\d+\s+reason:.+\bby-user\s*-->",
46
56
  re.IGNORECASE | re.DOTALL)
@@ -168,6 +178,42 @@ const hasOpen = uniqueReasons.some((reason) => reason.startsWith('proof:'))
168
178
  .some(({ status }) => status === CENSUS_VERDICTS.open);
169
179
  if (hasOpen) uniqueReasons.push('open');
170
180
  const delta = active === null ? null : diffCensus(active, fresh);
181
+ // The guard already knows WHAT moved; report it instead of forcing a blocked
182
+ // consumer to rebuild the diff from the kit internals.
183
+ const pathHashes = (entries) => new Map(
184
+ (Array.isArray(entries) ? entries : []).map(({ path, hash }) => [path, hash]),
185
+ );
186
+ const diffEntries = (before, after) => {
187
+ const previous = pathHashes(before);
188
+ const next = pathHashes(after);
189
+ return {
190
+ added: [...next.keys()].filter((path) => !previous.has(path)).sort(),
191
+ changed: [...next.keys()]
192
+ .filter((path) => previous.has(path) && previous.get(path) !== next.get(path)).sort(),
193
+ removed: [...previous.keys()].filter((path) => !next.has(path)).sort(),
194
+ };
195
+ };
196
+ const familyIndex = (families) => new Map([
197
+ ...(families?.surfaces ?? []), ...(families?.behaviors ?? []),
198
+ ].map(({ name, status, type }) => [`${type}:${name}`, status]));
199
+ const familyDelta = (before, after) => {
200
+ const previous = familyIndex(before);
201
+ const next = familyIndex(after);
202
+ return {
203
+ added: [...next.keys()].filter((key) => !previous.has(key)).sort(),
204
+ removed: [...previous.keys()].filter((key) => !next.has(key)).sort(),
205
+ statusChanged: [...next.keys()]
206
+ .filter((key) => previous.has(key) && previous.get(key) !== next.get(key)).sort()
207
+ .map((key) => `${key}: ${previous.get(key)} → ${next.get(key)}`),
208
+ };
209
+ };
210
+ const deltaReport = active === null ? null : {
211
+ denominator: {
212
+ added: delta.added, changed: delta.changed, removed: delta.removed,
213
+ },
214
+ evidence: diffEntries(active.evidence, fresh.evidence),
215
+ families: { ...familyDelta(active.families, fresh.families), open: delta.open },
216
+ };
171
217
  const denominatorUnchanged = delta !== null
172
218
  && Object.values(delta).every((paths) => paths.length === 0);
173
219
  const familiesUnchanged = active !== null
@@ -184,6 +230,7 @@ const state = resolveCensusState({
184
230
  writeResult(JSON.stringify({
185
231
  builderVersion: CENSUS_BUILDER_VERSION,
186
232
  changeBinding: fresh.fingerprints.topology,
233
+ delta: deltaReport,
187
234
  fresh: {
188
235
  ...fresh,
189
236
  profileReport: {
@@ -367,18 +414,71 @@ def extract_content(payload: dict) -> str:
367
414
  return ""
368
415
 
369
416
 
370
- def extract_issue(payload: dict, content: str):
371
- # Content anchor first, filename fallback. Deliberately NO branch fallback:
372
- # a meta/tooling handoff carries no issue (handoff skill supports this) and
373
- # must fail-open; a branch-based guess would mis-attribute the branch's issue
374
- # and false-block. None here → should_block() allows (not the stale handoff we guard).
375
- m = ISSUE_ANCHOR_RE.search(content or "")
376
- if m:
377
- return int(m.group(1))
417
+ def own_repository_slugs(repo_root: Path) -> list[str]:
418
+ """Return the `host/owner/repo` slugs this repository's remotes point at."""
419
+ try:
420
+ completed = subprocess.run(
421
+ ["git", "-C", str(repo_root), "config", "--get-regexp", r"^remote\..*\.url$"],
422
+ capture_output=True,
423
+ text=True,
424
+ timeout=5,
425
+ )
426
+ except (OSError, subprocess.SubprocessError):
427
+ return []
428
+ if completed.returncode != 0:
429
+ return []
430
+ slugs = []
431
+ for line in completed.stdout.splitlines():
432
+ parts = line.split(None, 1)
433
+ if len(parts) != 2:
434
+ continue
435
+ match = REMOTE_URL_RE.match(parts[1].strip())
436
+ if match is None:
437
+ continue
438
+ slug = f"{match.group('host')}/{match.group('owner')}/{match.group('repo')}"
439
+ if slug not in slugs:
440
+ slugs.append(slug)
441
+ return slugs
442
+
443
+
444
+ def own_issue_pattern(repo_root):
445
+ """Match only issue links of the repository that owns the handoff.
446
+
447
+ A handoff routinely links the upstream issues a session produced. Without
448
+ this restriction the first `/issues/<n>` in the prose wins, so a foreign
449
+ link hijacks the anchor and the guard reports an unrelated issue number.
450
+ """
451
+ if repo_root is None:
452
+ return None
453
+ slugs = own_repository_slugs(repo_root)
454
+ if not slugs:
455
+ return None
456
+ alternatives = "|".join(re.escape(slug) for slug in slugs)
457
+ return re.compile(rf"(?<![A-Za-z0-9.-])(?:{alternatives})/issues/(\d+)", re.IGNORECASE)
458
+
459
+
460
+ def extract_issue(payload: dict, content: str, repo_root=None):
461
+ # Own-repository content anchor first, then the skill-controlled filename.
462
+ # The unrestricted content anchor only remains for a repository whose remote
463
+ # is unparsable — there the filename is the only trustworthy signal, and a
464
+ # handoff without one keeps today's behaviour. Deliberately NO branch
465
+ # fallback: a meta/tooling handoff carries no issue (handoff skill supports
466
+ # this) and must fail-open; a branch-based guess would mis-attribute the
467
+ # branch's issue and false-block. None here → should_block() allows (not the
468
+ # stale handoff we guard).
469
+ own = own_issue_pattern(repo_root)
470
+ if own is not None:
471
+ m = own.search(content or "")
472
+ if m:
473
+ return int(m.group(1))
378
474
  fp = (payload.get("tool_input") or {}).get("file_path", "")
379
475
  m = FILENAME_ISSUE_RE.search(fp)
380
476
  if m:
381
477
  return int(m.group(1))
478
+ if own is None:
479
+ m = ISSUE_ANCHOR_RE.search(content or "")
480
+ if m:
481
+ return int(m.group(1))
382
482
  return None
383
483
 
384
484
 
@@ -534,15 +634,89 @@ def evaluate_census(repo_root: Path, proof_timeout_ms=CENSUS_PROOF_TIMEOUT_MS) -
534
634
  "override_applied": override_applied,
535
635
  "change_binding": result["changeBinding"],
536
636
  "mechanical_false_positive": result["mechanicalFalsePositive"],
637
+ "delta": result.get("delta"),
537
638
  }
538
639
 
539
640
 
540
- def build_census_block_message(issue: int, result: dict) -> str:
641
+ def _cap_entries(entries, limit: int):
642
+ if not isinstance(entries, list) or len(entries) <= limit:
643
+ return entries
644
+ return [*entries[:limit], f"…and {len(entries) - limit} more"]
645
+
646
+
647
+ def cap_delta(delta, limit: int = CENSUS_DELTA_LIMIT):
648
+ """Keep a diagnostic delta readable — head plus an honest remainder count."""
649
+ if not isinstance(delta, dict):
650
+ return delta
651
+ capped = {}
652
+ for group, entries in delta.items():
653
+ if isinstance(entries, dict):
654
+ capped[group] = {key: _cap_entries(value, limit) for key, value in entries.items()}
655
+ else:
656
+ capped[group] = _cap_entries(entries, limit)
657
+ return capped
658
+
659
+
660
+ def worktree_identity(path):
661
+ """Return (checkout root, shared git dir) or None when git cannot answer."""
662
+ try:
663
+ completed = subprocess.run(
664
+ ["git", "-C", str(path), "rev-parse", "--show-toplevel", "--git-common-dir"],
665
+ capture_output=True,
666
+ text=True,
667
+ timeout=5,
668
+ )
669
+ except (OSError, subprocess.SubprocessError):
670
+ return None
671
+ if completed.returncode != 0:
672
+ return None
673
+ lines = completed.stdout.splitlines()
674
+ if len(lines) != 2 or not lines[0] or not lines[1]:
675
+ return None
676
+ # `--git-common-dir` may answer relative to the directory git ran in.
677
+ toplevel = Path(os.path.realpath(lines[0]))
678
+ common = Path(os.path.realpath(Path(path) / lines[1]))
679
+ return toplevel, common
680
+
681
+
682
+ def checkout_diagnostic_lines(census_root, cwd=None) -> list[str]:
683
+ """Name the checkout the census verdict describes.
684
+
685
+ The census root comes from the handoff TARGET path, not from the session's
686
+ working directory — handoff documents live in the main checkout so they
687
+ survive worktree cleanup. A census describes the tree it was scanned in, so
688
+ a refresh in another worktree legitimately does not count here; without this
689
+ diagnosis the block reads as simply wrong to a session working elsewhere.
690
+ """
691
+ if census_root is None:
692
+ return []
693
+ lines = [
694
+ f" · evaluated checkout: {census_root}",
695
+ " (derived from the handoff target path — a census refresh in another",
696
+ " worktree does not count for this checkout)",
697
+ ]
698
+ try:
699
+ # A diagnostic must never cost the block: an unusable working directory
700
+ # only drops the extra sentence.
701
+ here = worktree_identity(Path.cwd() if cwd is None else Path(cwd))
702
+ there = worktree_identity(census_root)
703
+ except OSError:
704
+ return lines
705
+ if here and there and here[1] == there[1] and here[0] != there[0]:
706
+ lines += [
707
+ " · your working directory is a different worktree of the same repository:",
708
+ f" {here[0]} — refresh the evaluated checkout above, not this one",
709
+ ]
710
+ return lines
711
+
712
+
713
+ def build_census_block_message(issue: int, result: dict, census_root=None, cwd=None) -> str:
541
714
  reasons = ", ".join(result.get("reasons", [])) or "activated census is stale"
542
715
  lines = [
543
716
  f"CENSUS — Build-Handoff für #{issue} BLOCKED ({result.get('state', 'refresh_required')}):",
544
717
  "",
545
718
  f" · {reasons}",
719
+ *checkout_diagnostic_lines(census_root, cwd),
546
720
  " · run `$census-update` and activate a verified current census",
547
721
  ]
548
722
  if result.get("overrides"):
@@ -557,24 +731,38 @@ def should_block(payload: dict):
557
731
  if not is_handoff_write(payload):
558
732
  return False, ""
559
733
  content = extract_content(payload)
560
- issue = extract_issue(payload, content)
734
+ # The owning repository decides which issue links may anchor the handoff,
735
+ # so it is resolved before the anchor is extracted.
736
+ census_root = None
737
+ root_error = None
738
+ try:
739
+ census_root = resolve_handoff_repo_root(payload)
740
+ except Exception as error:
741
+ root_error = error
742
+ issue = extract_issue(payload, content, census_root)
561
743
  if issue is None:
562
744
  log(HOOK_NAME, "no identifiable issue target → fail-open allow")
563
745
  return False, ""
564
746
  intent = _infer_intent(content)
565
- try:
566
- census_root = resolve_handoff_repo_root(payload)
567
- census = evaluate_census(census_root)
568
- except Exception as error:
569
- census = {
747
+
748
+ def unavailable(error):
749
+ return {
570
750
  "state": "failed",
571
751
  "block_handoff": intent == "build",
572
752
  "reasons": [f"target repository unavailable ({error})"],
573
753
  "overrides": [],
574
754
  }
755
+
756
+ if census_root is None:
757
+ census = unavailable(root_error)
758
+ else:
759
+ try:
760
+ census = evaluate_census(census_root)
761
+ except Exception as error:
762
+ census = unavailable(error)
575
763
  log(HOOK_NAME, f"census state={census['state']} reasons={census.get('reasons', [])}")
576
764
  if intent == "build" and census.get("block_handoff"):
577
- return True, build_census_block_message(issue, census)
765
+ return True, build_census_block_message(issue, census, census_root)
578
766
  if GUARD_ACK_RE.search(content):
579
767
  log(HOOK_NAME, "guard-ack override present → allow graph gate only")
580
768
  return False, ""
@@ -608,7 +796,8 @@ def build_block_message(issue: int, intent: str, result: dict) -> str:
608
796
 
609
797
 
610
798
  def main() -> int:
611
- if sys.argv[1:] == ["--census-status"]:
799
+ arguments = sys.argv[1:]
800
+ if arguments[:1] == ["--census-status"] and arguments[1:] in ([], ["--verbose"]):
612
801
  try:
613
802
  root = resolve_census_root_from_cwd(Path.cwd())
614
803
  result = evaluate_census(root)
@@ -617,6 +806,8 @@ def main() -> int:
617
806
  "detail": f"target repository unavailable ({error})",
618
807
  "reasons": ["repository"], "overrides": [],
619
808
  "override_applied": False}
809
+ if arguments[1:] != ["--verbose"]:
810
+ result["delta"] = cap_delta(result.get("delta"))
620
811
  print(json.dumps(result, sort_keys=True))
621
812
  return 0
622
813
  try:
@@ -52,7 +52,7 @@ For a cross-cutting plan, run `python3 .claude/hooks/drift-guard.py --census-sta
52
52
 
53
53
  ## Plan-lock — writing PLAN.md
54
54
 
55
- Once all decisions are made (plan locked, before sign-off/handoff) and the session runs in a **worktree** → write the locked plan as `PLAN.md` to the worktree root (gitignored since, doesn't travel over git — consistent with CLAUDE.md "plan inside the worktree"). This way the locked plan survives a session cut and `to-prd` finds its default source. Continuing same-session work **without** a worktree stays allowed (conversation = source); but for a deliberate **session cut** before `to-prd`, `PLAN.md` is mandatory. (The `-codex` variant already writes `PLAN.md` anyway.)
55
+ Once all decisions are made (plan locked, before sign-off/handoff) and the session runs in a **worktree** → write the locked plan as `PLAN.md` to the worktree root. `PLAN.md` is **expected** to be ignored by git so it never travels over git — the kit never edits your `.gitignore` by itself, but `/setup-workflow` offers to add that rule (consistent with CLAUDE.md "plan inside the worktree"). This way the locked plan survives a session cut and `to-prd` finds its default source. Continuing same-session work **without** a worktree stays allowed (conversation = source); but for a deliberate **session cut** before `to-prd`, `PLAN.md` is mandatory. (The `-codex` variant already writes `PLAN.md` anyway.)
56
56
 
57
57
  ## Re-grill reconcile — execute-ready (Wave 26)
58
58
 
@@ -40,7 +40,7 @@ For a cross-cutting plan, run `python3 .claude/hooks/drift-guard.py --census-sta
40
40
 
41
41
  ## Plan-lock — writing PLAN.md
42
42
 
43
- Once all decisions are made (plan locked, before sign-off/handoff) and the session runs in a **worktree** → write the locked plan as `PLAN.md` to the worktree root (gitignored since, doesn't travel over git — consistent with CLAUDE.md "plan inside the worktree"). This way the locked plan survives a session cut and `to-prd` finds its default source. Continuing same-session work **without** a worktree stays allowed (conversation = source); but for a deliberate **session cut** before `to-prd`, `PLAN.md` is mandatory. (The `-codex` variant already writes `PLAN.md` anyway.)
43
+ Once all decisions are made (plan locked, before sign-off/handoff) and the session runs in a **worktree** → write the locked plan as `PLAN.md` to the worktree root. `PLAN.md` is **expected** to be ignored by git so it never travels over git — the kit never edits your `.gitignore` by itself, but `/setup-workflow` offers to add that rule (consistent with CLAUDE.md "plan inside the worktree"). This way the locked plan survives a session cut and `to-prd` finds its default source. Continuing same-session work **without** a worktree stays allowed (conversation = source); but for a deliberate **session cut** before `to-prd`, `PLAN.md` is mandatory. (The `-codex` variant already writes `PLAN.md` anyway.)
44
44
 
45
45
  </plan-lock>
46
46
 
@@ -105,6 +105,19 @@ release contain the same artifact.
105
105
  and safe routes—never file content. Automated update pull
106
106
  requests carry the same availability summary and remain manual-merge only.
107
107
 
108
+ Read the required consumer migrations alongside the file delta. A release
109
+ that forces the Consumer to commit a decision registers it as declarative,
110
+ versioned data in the Kit's migration registry — never as release-note prose.
111
+ Preview and the terminal report expose the same structured record, so an
112
+ interactive run, a `--yes` run, `update --json`, and the automated update
113
+ pull request all name the same outstanding actions. Each action names the
114
+ workflow that resolves it, the Consumer file that carries the decision, and
115
+ the exact missing decision. `kit-update` only detects and reports it: it
116
+ never writes the decision, never infers a value, and never invents or
117
+ auto-accepts a pattern that would grant deletion authority. An empty but
118
+ explicit decision is a committed decision and clears the action; a missing
119
+ one keeps it pending on every rerun.
120
+
108
121
  For each conflicted kit-shipped file, always ask the user whether the local
109
122
  edit is a generic improvement or project-specific; never decide or act
110
123
  automatically. For a generic improvement retained as a bridge, run
@@ -131,7 +144,26 @@ release contain the same artifact.
131
144
  resume the transaction through the update API's `resumeFrom` option. Do not
132
145
  copy staged files into the consumer by hand.
133
146
 
134
- 5. Check the optional project census after the update:
147
+ 5. Review the consumer profile's glob dialect after the update:
148
+
149
+ ```sh
150
+ python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json
151
+ ```
152
+
153
+ Every consumer-profile glob — Worktree Lifecycle `scratchPatterns` and
154
+ `wrapup.landingGeneratedArtifactPatterns`, Workflow Advisories
155
+ `baseline.sourceGlobs` and the `preRefactor`/`stopChecks` surface globs — is
156
+ matched by one shared repository-relative dialect. A pattern written for an
157
+ older matcher can narrow or widen. The check names each such pattern with
158
+ the witness path that proves the difference and marks the keys that carry
159
+ deletion authority; exit code 1 means at least one needs review. Report the
160
+ named patterns and let the user rewrite them. The updater never rewrites a
161
+ consumer pattern, and a widened deletion-authority pattern is never accepted
162
+ silently — an update must not expand what cleanup may remove. A missing or
163
+ disabled profile leaves nothing to review and does not invalidate the
164
+ update.
165
+
166
+ 6. Check the optional project census after the update:
135
167
 
136
168
  ```sh
137
169
  python3 .claude/hooks/drift-guard.py --census-status
@@ -146,8 +146,8 @@ replace the ~30-second heartbeat; otherwise the standing heartbeat remains requi
146
146
  points there). Bring its branch to current `main`:
147
147
  `git -C <wave> merge --ff-only origin/main` (if your repo guards destructive
148
148
  git, ff-merge is the safe path — not `reset --hard`). Install dependencies with
149
- your package manager after (the lockfile may have moved). A gitignored plan doc
150
- survives the merge.
149
+ your package manager after (the lockfile may have moved). A plan doc the repository
150
+ ignores survives the merge; a tracked one may not (`/setup-workflow` offers those rules).
151
151
  5. **Run project setup steps (`§Setup`)** the later verify needs — e.g. a DB
152
152
  tunnel or service the live-verify depends on. Absent layer → start whatever your
153
153
  live-verify environment requires before Phase 4.
@@ -332,8 +332,8 @@ written · merge order documented.
332
332
  - **Release this run's slice claims.** A landed slice's PR supersedes its claim;
333
333
  for a pulled or abandoned slice remove the claim this run planted (unassign +
334
334
  `claim released` comment) so it stays grabbable. Foreign ones stay untouched.
335
- - **Before removing any slice worktree, read its `ANNAHMEN.md`** (an assumptions
336
- log, gitignored at worktree-root) and propagate each build-time assumption marker
335
+ - **Before removing any slice worktree, read its `ANNAHMEN.md`** (an assumptions log at
336
+ the worktree root, **expected** to be ignored by git — `/setup-workflow` offers that rule) and propagate each build-time assumption marker
337
337
  to the sibling issue it carries. A hand-driven multi-PR / migration landing does
338
338
  NOT run `wrapup`'s assumption-propagation step — this is the only place it
339
339
  happens; `worktree remove` deletes the log.
@@ -37,6 +37,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
37
37
  | `docs/agents/workflow-capabilities.json` + capability assets | consumer-owned Worktree Lifecycle, Memory Lifecycle, Workflow Advisories, and Safety Guardrails choices (Sections A4–A7) |
38
38
  | `.claude/settings.json` | additive activation of the advisory kit-origin Edit/Write hint (Section A8) |
39
39
  | user-local routing profile | familiar agent surfaces + switching autonomy (Section A9; never package- or repository-owned) |
40
+ | `.gitignore` | **offered only** — one previewed, append-only marker block with the ignore rules for the planning artifacts the skills write (Section A11) |
40
41
 
41
42
  ## Idempotency contract — read before writing anything
42
43
 
@@ -216,6 +217,15 @@ Worktree Lifecycle for this repository?"* Offer exactly **Yes**, **Later**, and
216
217
  - **Disable** — remove only the exact kit-owned hook commands first, then set
217
218
  `enabled: false`; retain the profile, setup policy, and unknown keys.
218
219
 
220
+ Whenever a profile already carries patterns — on **Yes** over an existing
221
+ section and on **Existing** — run
222
+ `python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json` and
223
+ report every pattern it names. Patterns whose match set narrows or widens under
224
+ the shared dialect are a consumer decision: name them with their witness path,
225
+ mark the deletion-authority keys, and let the consumer rewrite them. Never
226
+ rewrite a pattern for them, and never accept a widened deletion-authority
227
+ pattern silently.
228
+
219
229
  The default setup entry is
220
230
  `python3 scripts/worktree-lifecycle/setup.py`; a proven consumer-native helper
221
231
  may remain the configured `setupEntry` for parity. The handoff advisory always
@@ -273,6 +283,12 @@ repository?"* Offer exactly **Yes**, **Later**, and **No**.
273
283
  - **Disable** — remove exact kit-owned commands first, then set `enabled:
274
284
  false`; preserve every profile value and unknown key.
275
285
 
286
+ `baseline.sourceGlobs` and the `preRefactor`/`stopChecks` surface globs use the
287
+ same shared dialect as the Worktree Lifecycle patterns above. An adopted
288
+ profile written for the older whole-string matcher can change meaning, so run
289
+ `python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json` and
290
+ report every pattern it names before treating the section as reconciled.
291
+
276
292
  ### 2f. Section A7 — Optional Safety Guardrails
277
293
 
278
294
  > Safety Guardrails is a counted group of seven independently selectable
@@ -390,6 +406,55 @@ machine, checkout path, repository name, credentials, or current GitHub login.
390
406
  Even a valid capability authorizes local preparation only; a pull request,
391
407
  push, publish, or merge remains a separate explicit approval.
392
408
 
409
+ ### 2j. Section A11 — Planning-artifact ignore rules (offered)
410
+
411
+ > The planning skills write `PLAN.md`, `PLAN-REVIEW-LOG.md`, and `ANNAHMEN.md`
412
+ > into a session worktree. `.gitignore` is the consumer's file: the kit does not
413
+ > own it and never touches it during `init` or `update`, so those artifacts are
414
+ > ignored only if this repository already says so. Without the rules every
415
+ > planning worktree reports a permanently dirty tree, the Worktree Lifecycle
416
+ > cleanup refuses to remove it, and a plan doc can be committed by accident.
417
+
418
+ Read the current state first. The preview is read-only and writes nothing:
419
+
420
+ ```bash
421
+ python3 scripts/worktree-lifecycle/ignore_seed.py preview
422
+ ```
423
+
424
+ - `nothing-to-do` — this repository already ignores every declared artifact.
425
+ Report it and ask nothing.
426
+ - `blocked` — the marker block exists but no longer covers everything. That
427
+ block is consumer-owned: name the uncovered artifacts, let the user edit
428
+ `.gitignore` themselves, and never repair or rewrite it.
429
+ - `append` — show the exact lines the preview printed, the whole marker block
430
+ verbatim and nothing summarized, then ask: *"Should setup add these ignore
431
+ rules to `.gitignore`?"*
432
+
433
+ Offer exactly two choices:
434
+
435
+ - **Add the rules** — run the seeder once. It appends that one marker block and
436
+ touches nothing else:
437
+
438
+ ```bash
439
+ python3 scripts/worktree-lifecycle/ignore_seed.py apply
440
+ ```
441
+
442
+ - **Not now** — write nothing. Explain that the planning skills still work, that
443
+ the artifacts will keep showing up as untracked changes, and that re-running
444
+ `/setup-workflow` offers this again.
445
+
446
+ The write is append-only inside one idempotent marker block: it never rewrites,
447
+ reorders, or removes an existing line; a re-run after an approval is a
448
+ byte-identical no-op; and a decline leaves the file exactly as it was. An
449
+ artifact already committed to history is reported separately — an ignore rule
450
+ cannot untrack it, and setup never runs `git rm`. Only this explicit, approved
451
+ step may write `.gitignore`; `init` and `update` reconciliation never reach the
452
+ seeder.
453
+
454
+ Report `appended · nothing to do (already ignored) · declined · blocked
455
+ (consumer-edited block)`. After an approval — and only then — the Section A4
456
+ `scratchPatterns` derivation has real ignored planning artefacts to read.
457
+
393
458
  ### 3. Section B — Triage labels
394
459
 
395
460
  > When `triage` processes an incoming issue it applies labels (or your tracker's equivalent). It needs strings you've actually configured, or it creates duplicates.
@@ -411,13 +476,29 @@ Seed `docs/agents/domain.md` from [domain.md](./domain.md).
411
476
 
412
477
  **Skip entirely if Section A is not GitHub** → write `board-sync.md` with `state=not-applicable`, `mode=none`, a one-line "this project does not use a GitHub-Projects board" note. Terminal; never re-prompts.
413
478
 
414
- **Preflight:** `gh auth status`. If `project`/`read:project` scope is missing, surface the exact remedy: `gh auth refresh -s project,read:project`, then fall through to the stub path below.
479
+ **Preflight:** `gh auth status` (or `python3 scripts/board_bootstrap.py preflight --json` for the machine-readable answer). If `project`/`read:project` scope is missing, surface the exact remedy: `gh auth refresh -s project,read:project`, then fall through to the stub path below — without the write scope, never attempt creation.
415
480
 
416
- **Discover (the success path):** `gh project list --owner <remote-owner> --format json`. If **exactly one** project clearly belongs to this repo's owner, read its fields: `gh project field-list <number> --owner <owner> --format json`. Record each field's `id`, `name`, `dataType` (single-select / number / text), and for single-selects the option `id`s. Write `board-sync.md` with `state=filled`, `mode=github-projects-v2`, the project node id + repo + the discovered field/option IDs, seeded from [board-sync.md](./board-sync.md). Fill the `<!-- board-sync:profile -->` **JSON block** (the machine SSOT `scripts/board_config.py` parses) — replace each `<…>` placeholder under `repo`/`project`/`fields` with the discovered value; leave the convention values (`labels`/`branchPrefixes`/`prMarkers`/`headings`) at their seeded defaults unless this project's conventions differ.
481
+ **Discover (the existing-board path):** `gh project list --owner <remote-owner> --format json`. If **exactly one** project clearly belongs to this repo's owner, read its fields: `gh project field-list <number> --owner <owner> --format json`. Record each field's `id`, `name`, `dataType` (single-select / number / text), and for single-selects the option `id`s. Write `board-sync.md` with `state=filled`, `mode=github-projects-v2`, the project node id + repo + the discovered field/option IDs, seeded from [board-sync.md](./board-sync.md). Fill the `<!-- board-sync:profile -->` **JSON block** (the machine SSOT `scripts/board_config.py` parses) — replace each `<…>` placeholder under `repo`/`project`/`fields` with the discovered value; leave the convention values (`labels`/`branchPrefixes`/`prMarkers`/`headings`) at their seeded defaults unless this project's conventions differ.
417
482
 
418
483
  **Map the status roles (`fields.status.roles`):** the seeded map carries the recommended English stage names (`Idea/Triaged/Spec/In Progress/Review/Done`). Reconcile it against the **discovered** Status option names: an exact (case-sensitive) match keeps its seeded entry; for every role whose seeded name is NOT among the discovered options, ask the user which of their actual option names plays that role (a stage they don't have may be omitted from the map — say so). Never guess a mapping from similarity. Finish with a validation pass: every role value in the map MUST appear in `fields.status.options`, else fix before writing `state=filled`.
419
484
 
420
- **Fallback (the single catch-all no board / >1 / ambiguous / scope error / read failure):** do **not** auto-create a board (`gh project create` alone cannot provision the Status options + workflow fields a board needs). Write `board-sync.md` with `state=stub`, `mode=github-projects-v2`, and inline **instructions**: which fields the workflow profile needs (a Status single-select with your stage options; optionally a Wave number, a Cluster text, a Spec-Path / Plan-Path text), how to create the board in the GitHub UI / `gh`, and "then run `/setup-workflow` againit will discover and fill the IDs." Retryable.
485
+ **Offer creation (no board found, scope present):** a fresh repo can reach a working board in this run `gh project create` provisions the shell and `gh project field-create --single-select-options` provisions the Status single-select together with its options. Minting a GitHub Project is an outward, user-visible action, so **ask first and never create a board without an explicit yes**. Name the stage options the board will get (the seed's `fields.status.roles` values) in the question, and edit those role values in the seed *before* creating if the user wants a different stage vocabulary they become the board's Status options verbatim. Present exactly two choices:
486
+
487
+ - **Create it now** — run the one creation path (never hand-assemble the `gh` sequence):
488
+
489
+ ```bash
490
+ python3 scripts/board_bootstrap.py create --owner <owner> --repo <owner>/<repo> \
491
+ --title "<repo> Workflow" --seed <path of this skill folder>/board-sync.md \
492
+ --out docs/agents/board-sync.md
493
+ ```
494
+
495
+ It re-checks the destination and the scope before it writes anything, creates the project, the Status single-select (option names read from the seed profile's `fields.status.roles` — the board's own stage vocabulary, never a literal), and the Wave / Cluster / Spec-Path / Plan-Path fields, then discovers the real field/option IDs via `gh project field-list` and writes `docs/agents/board-sync.md` at `state=filled`, `mode=github-projects-v2` **from that readback**. Show the user `--dry-run` output first if they want to see the exact sequence. Report the created project's number and URL, and that its Status roles now resolve.
496
+
497
+ - **Not now** — create nothing and take the stub path below unchanged.
498
+
499
+ **A non-zero exit is a stub, never a partial claim.** The helper writes no profile when the destination is consumer-owned, the scope is missing (exit 3), or creation/readback/validation fails — so a half-created board can never produce a profile claiming fields that do not exist. On any non-zero exit, relay its message (it names the project it created, if any, so the user can finish or delete it), then take the stub path; never hand-write a `state=filled` profile and never retry creation without asking again.
500
+
501
+ **Fallback (the single catch-all — declined / no board / >1 / ambiguous / scope error / read failure / creation failure):** write `board-sync.md` with `state=stub`, `mode=github-projects-v2`, and inline **instructions**: which fields the workflow profile needs (a Status single-select with your stage options; optionally a Wave number, a Cluster text, a Spec-Path / Plan-Path text), how to create the board in the GitHub UI / `gh`, and "then run `/setup-workflow` again — it will discover and fill the IDs." Retryable.
421
502
 
422
503
  **Optional — Phase field + saved Views (Program route only):** never
423
504
  auto-discovered or auto-created, unlike the fields above — a Phase field's
@@ -3,8 +3,8 @@
3
3
  How the board-managed workflow skills (`to-prd`, `to-issues`, `board-to-waves`, …) address this project's GitHub-Projects board. A board stores its fields under opaque GraphQL IDs that differ per board, so they are recorded here rather than hardcoded in any skill.
4
4
 
5
5
  `/setup-workflow` writes this file in one of three states (see the sentinel on the first line):
6
- - **`state=filled`** — the IDs below were discovered from your board.
7
- - **`state=stub`** — no single board was found; fill the IDs by creating the board (below) and re-running `/setup-workflow`.
6
+ - **`state=filled`** — the IDs below were discovered from your board (an existing one, or the one `/setup-workflow` offered to create for you).
7
+ - **`state=stub`** — no single board was filled: you declined the creation offer, `gh` lacked the `project` scope, several boards were ambiguous, or creation failed. Create the board (below) and re-run `/setup-workflow`.
8
8
  - **`state=not-applicable`** (`mode=none`) — this project does not use a GitHub-Projects board.
9
9
 
10
10
  ## Board profile — fields the workflow skills use
@@ -106,6 +106,10 @@ their titles unchanged; set it once to match your board's language.
106
106
 
107
107
  ## If the IDs are not yet filled (stub)
108
108
 
109
+ **Let setup create the board.** Ensure `gh` has the scopes (`gh auth refresh -s project,read:project`) and re-run `/setup-workflow`: with no board and the scope present it *offers* to create one. On your yes it runs `scripts/board_bootstrap.py`, which creates the project plus the Status single-select (its options named after the `roles` map below) and the Wave / Cluster / Spec-Path / Plan-Path fields, reads the real IDs back, and rewrites this file at `state=filled`. Nothing is created without that explicit yes, and a failed run leaves this stub in place rather than a half-true profile.
110
+
111
+ **Or build it by hand:**
112
+
109
113
  1. Create a GitHub-Projects (v2) board for this owner and add the fields above (at minimum a `Status` single-select with your stage options — the recommended stage names are `Idea, Triaged, Spec, In Progress, Review, Done`, matching the seeded `roles` defaults).
110
114
  2. Ensure `gh` has the scopes: `gh auth refresh -s project,read:project`.
111
115
  3. Re-run `/setup-workflow` — it discovers the board (`gh project field-list`) and fills the IDs here automatically.
@@ -74,6 +74,40 @@ key. Consumer values are never normalized on adoption.
74
74
  Empty command surfaces are honest inactive defaults. Setup recommends concrete
75
75
  project commands from the tools already present, then asks before activating.
76
76
 
77
+ ## Profile glob dialect
78
+
79
+ `baseline.sourceGlobs`, `preRefactor.surfaces[].globs`, and
80
+ `stopChecks.surfaces[].globs` are matched by the one shared dialect in
81
+ `scripts/profile_globs.py` — the same matcher Worktree Lifecycle uses for its
82
+ scratch and landing-artifact policies. There is no second matcher and no
83
+ per-capability variant:
84
+
85
+ - `*` matches any run of characters inside one path segment, never `/`.
86
+ - `?` matches exactly one character inside one path segment.
87
+ - `[seq]` and `[!seq]` are per-segment character classes; `/` is always a
88
+ separator and never a class member.
89
+ - `**` as a complete segment matches zero or more segments, so a leading `**/`
90
+ also matches the repository root and `dir/**` also matches `dir` itself.
91
+ - Matching is always case-sensitive, on every host filesystem.
92
+ - A pattern must match the whole repository-relative path.
93
+
94
+ Thus `src/**` covers `src/a/b.ts`, `**/*.ts` covers both `index.ts` and
95
+ `src/index.ts`, and `*.ts` covers only a root-level file.
96
+
97
+ A profile written for the older whole-string matcher can therefore change
98
+ meaning. When adopting an existing profile, or after any kit update, review it:
99
+
100
+ ```bash
101
+ python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json
102
+ ```
103
+
104
+ The check names every pattern whose match set narrows or widens, prints the
105
+ concrete witness path that proves the difference, and separately flags patterns
106
+ that only matched case-insensitively on the previous matcher. Exit code 1 means
107
+ at least one pattern needs review. Report the named patterns and let the
108
+ consumer rewrite them; never migrate a pattern automatically. The check reads
109
+ the profile and never edits it.
110
+
77
111
  ## Hook ownership
78
112
 
79
113
  - PreToolUse on Read: