@deftai/directive-content 0.55.1 → 0.56.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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env python3
2
+ """vbrief_reconcile_labels.py -- SCM label reconciliation (#1288).
3
+
4
+ The reverse-direction companion to ``task vbrief:reconcile:graph`` (#1287):
5
+ where the graph walker promotes proposed/ vBRIEFs as their dependencies
6
+ clear, this verb (``task vbrief:reconcile:labels``) keeps the *forge*
7
+ label surface in sync with canonical vBRIEF state so reviewers never see
8
+ drift between an issue's labels and its lifecycle.
9
+
10
+ Mapping table (canonical vBRIEF state -> managed labels):
11
+
12
+ * ``plan.status == "blocked"`` OR any unresolved
13
+ ``plan.metadata.swarm.depends_on[]`` entry -> ``status:blocked``
14
+ * ``plan.metadata.kind == "epic"`` -> ``epic`` + ``status:tracker``
15
+ * ``plan.metadata.kind == "research"`` -> ``rfc``
16
+
17
+ A dependency is *unresolved* when the brief it names does not (yet) live
18
+ in a terminal lifecycle folder (``completed/`` or ``cancelled/``) -- the
19
+ exact same resolution rule the #1287 graph walker uses, reused here via
20
+ :data:`vbrief_reconcile_graph.RESOLVED_FOLDERS` /
21
+ :func:`vbrief_reconcile_graph._dep_resolved`.
22
+
23
+ Design contract:
24
+
25
+ * **Mirror, don't accumulate.** The verb manages exactly the four labels
26
+ in :data:`MANAGED_LABELS`. On each run it ADDS the managed labels the
27
+ mapping currently demands and REMOVES managed labels that no longer
28
+ apply. Labels outside the managed set (``bug``, ``priority:high``, ...)
29
+ are never touched.
30
+ * **Forge-agnostic.** Every forge call routes through ``scripts/scm.py``
31
+ (#1145) via :func:`scm.call`; ``task verify:scm-boundary`` enforces no
32
+ direct ``gh`` invocation remains. The default :class:`ScmLabelClient`
33
+ is the only thing that talks to the forge, and it is injectable so the
34
+ test suite never makes a live ``gh`` call.
35
+ * **Idempotent.** A second run is a no-op: the first run already brought
36
+ the label set to the desired state, so the computed add/remove diff is
37
+ empty and no mutation fires.
38
+
39
+ Exit codes (three-state, mirrors ``scripts/vbrief_reconcile_graph.py``):
40
+
41
+ 0 -- ran successfully (zero or more labels reconciled).
42
+ 1 -- one or more per-issue forge calls failed.
43
+ 2 -- usage / config error (no ``vbrief/`` directory under
44
+ ``--project-root``).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import json
51
+ import sys
52
+ from collections.abc import Sequence
53
+ from dataclasses import dataclass, field
54
+ from pathlib import Path
55
+ from typing import Protocol
56
+
57
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
58
+
59
+ import scm # noqa: E402
60
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
61
+ from swarm_readiness import _all_scope_ids, _as_str_list # noqa: E402
62
+ from triage_reconcile import _extract_issue_ref # noqa: E402
63
+ from vbrief_reconcile_graph import _dep_resolved # noqa: E402
64
+
65
+ reconfigure_stdio()
66
+
67
+ #: Lifecycle folders whose vBRIEFs carry an actionable label state. The
68
+ #: mapping concepts (blocked / epic / research) are all in-flight, so the
69
+ #: terminal folders (completed/cancelled) are not scanned for label
70
+ #: application; they DO participate in dependency resolution via
71
+ #: :func:`_all_scope_ids` below.
72
+ SCAN_FOLDERS = ("proposed", "pending", "active")
73
+
74
+ #: The complete set of labels this verb owns. Only these are ever added
75
+ #: or removed; everything else on an issue is left untouched.
76
+ MANAGED_LABELS = ("status:blocked", "epic", "status:tracker", "rfc")
77
+
78
+ #: scm.call source identity (#1145). v1 supports only github-issue.
79
+ SCM_SOURCE = "github-issue"
80
+
81
+
82
+ class ScmLabelError(RuntimeError):
83
+ """Raised when a forge label read / mutation fails."""
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Mapping
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def compute_desired_labels(plan: dict, *, unresolved_deps: bool) -> set[str]:
92
+ """Return the managed labels the mapping table demands for *plan*.
93
+
94
+ The result is always a subset of :data:`MANAGED_LABELS`. ``epic`` and
95
+ ``research`` are mutually exclusive ``kind`` values, so the kind arm
96
+ uses ``elif``; ``status:blocked`` is orthogonal (a blocked epic gets
97
+ all three).
98
+ """
99
+ desired: set[str] = set()
100
+ status = plan.get("status")
101
+ metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
102
+ kind = metadata.get("kind")
103
+ if status == "blocked" or unresolved_deps:
104
+ desired.add("status:blocked")
105
+ if kind == "epic":
106
+ desired.update(("epic", "status:tracker"))
107
+ elif kind == "research":
108
+ desired.add("rfc")
109
+ return desired
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Forge client (injectable)
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class LabelClient(Protocol):
118
+ """The seam the reconciler talks to. Tests inject an in-memory fake."""
119
+
120
+ def fetch_labels(self, repo: str, issue_number: int) -> list[str]:
121
+ ...
122
+
123
+ def apply(
124
+ self,
125
+ repo: str,
126
+ issue_number: int,
127
+ add: Sequence[str],
128
+ remove: Sequence[str],
129
+ ) -> None:
130
+ ...
131
+
132
+
133
+ class ScmLabelClient:
134
+ """Forge-backed label client routing every call through ``scripts/scm.py``.
135
+
136
+ Both the read (``issue view --json labels``) and the mutation
137
+ (``issue edit --add-label/--remove-label``) go through
138
+ :func:`scm.call` with ``source="github-issue"`` so the #1145 scm
139
+ boundary is honoured -- ``task verify:scm-boundary`` flags any direct
140
+ ``gh`` invocation, and this client deliberately has none.
141
+ """
142
+
143
+ def fetch_labels(self, repo: str, issue_number: int) -> list[str]:
144
+ proc = scm.call(
145
+ SCM_SOURCE,
146
+ "issue",
147
+ ["view", str(issue_number), "--repo", repo, "--json", "labels"],
148
+ )
149
+ if proc.returncode != 0:
150
+ raise ScmLabelError(
151
+ f"issue view #{issue_number} ({repo}) failed: "
152
+ f"{(proc.stderr or '').strip()}"
153
+ )
154
+ try:
155
+ data = json.loads(proc.stdout or "{}")
156
+ except json.JSONDecodeError as exc:
157
+ raise ScmLabelError(
158
+ f"issue view #{issue_number} ({repo}) returned non-JSON: {exc}"
159
+ ) from exc
160
+ labels = data.get("labels") if isinstance(data, dict) else None
161
+ if not isinstance(labels, list):
162
+ return []
163
+ names: list[str] = []
164
+ for entry in labels:
165
+ if isinstance(entry, dict) and isinstance(entry.get("name"), str):
166
+ names.append(entry["name"])
167
+ elif isinstance(entry, str):
168
+ names.append(entry)
169
+ return names
170
+
171
+ def apply(
172
+ self,
173
+ repo: str,
174
+ issue_number: int,
175
+ add: Sequence[str],
176
+ remove: Sequence[str],
177
+ ) -> None:
178
+ args = ["edit", str(issue_number), "--repo", repo]
179
+ for name in add:
180
+ args += ["--add-label", name]
181
+ for name in remove:
182
+ args += ["--remove-label", name]
183
+ proc = scm.call(SCM_SOURCE, "issue", args)
184
+ if proc.returncode != 0:
185
+ raise ScmLabelError(
186
+ f"issue edit #{issue_number} ({repo}) failed: "
187
+ f"{(proc.stderr or '').strip()}"
188
+ )
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Outcome types
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ @dataclass
197
+ class LabelChange:
198
+ """A single issue's computed (and, unless dry-run, applied) label diff."""
199
+
200
+ story_id: str
201
+ repo: str
202
+ issue_number: int
203
+ current: list[str]
204
+ desired: list[str]
205
+ add: list[str]
206
+ remove: list[str]
207
+
208
+ def to_json(self) -> dict[str, object]:
209
+ return {
210
+ "story_id": self.story_id,
211
+ "repo": self.repo,
212
+ "issue_number": self.issue_number,
213
+ "current": list(self.current),
214
+ "desired": list(self.desired),
215
+ "add": list(self.add),
216
+ "remove": list(self.remove),
217
+ }
218
+
219
+
220
+ @dataclass
221
+ class ReconcileLabelsOutcome:
222
+ """Aggregate result of a single label-reconcile run."""
223
+
224
+ changed: list[LabelChange] = field(default_factory=list)
225
+ unchanged: list[LabelChange] = field(default_factory=list)
226
+ skipped_no_ref: list[str] = field(default_factory=list)
227
+ errors: list[tuple[str, str]] = field(default_factory=list)
228
+ dry_run: bool = False
229
+
230
+ def to_json(self) -> dict[str, object]:
231
+ return {
232
+ "changed": [c.to_json() for c in self.changed],
233
+ "unchanged": [c.to_json() for c in self.unchanged],
234
+ "skipped_no_ref": list(self.skipped_no_ref),
235
+ "errors": [{"story_id": sid, "message": msg} for sid, msg in self.errors],
236
+ "dry_run": self.dry_run,
237
+ }
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Core reconcile logic
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ def _unresolved_deps(
246
+ swarm: dict,
247
+ known_ids: dict[str, tuple[Path, str]],
248
+ ) -> bool:
249
+ """True when any ``depends_on`` entry has NOT resolved to a terminal folder.
250
+
251
+ Reuses :func:`vbrief_reconcile_graph._dep_resolved` so "resolved"
252
+ means exactly what the #1287 graph walker means: the named brief
253
+ lives in ``completed/`` or ``cancelled/``. An unknown dependency id
254
+ counts as unresolved (the dependent is still blocked on it).
255
+ """
256
+ return any(
257
+ not _dep_resolved(dep, known_ids)
258
+ for dep in _as_str_list(swarm.get("depends_on"))
259
+ )
260
+
261
+
262
+ def reconcile_labels(
263
+ project_root: Path,
264
+ *,
265
+ repo: str | None = None,
266
+ dry_run: bool = False,
267
+ client: LabelClient | None = None,
268
+ ) -> tuple[int, ReconcileLabelsOutcome]:
269
+ """Reconcile managed SCM labels against canonical vBRIEF state.
270
+
271
+ Walks :data:`SCAN_FOLDERS`, resolves each brief's linked issue from
272
+ its ``x-vbrief/github-issue`` reference (falling back to *repo* when
273
+ the reference URI lacks an owner/name segment), computes the
274
+ add/remove diff against :data:`MANAGED_LABELS`, and applies it via
275
+ *client* (unless *dry_run*). Returns ``(exit_code, outcome)``.
276
+ """
277
+ vbrief_dir = project_root / "vbrief"
278
+ if not vbrief_dir.is_dir():
279
+ return 2, ReconcileLabelsOutcome(dry_run=dry_run)
280
+
281
+ if client is None:
282
+ client = ScmLabelClient()
283
+
284
+ known_ids = _all_scope_ids(project_root)
285
+ outcome = ReconcileLabelsOutcome(dry_run=dry_run)
286
+ seen_issues: set[tuple[str, int]] = set()
287
+
288
+ for folder in SCAN_FOLDERS:
289
+ folder_path = vbrief_dir / folder
290
+ if not folder_path.is_dir():
291
+ continue
292
+ for path in sorted(folder_path.glob("*.vbrief.json")):
293
+ try:
294
+ data = json.loads(path.read_text(encoding="utf-8"))
295
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
296
+ continue
297
+ if not isinstance(data, dict):
298
+ continue
299
+ plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
300
+ story_id = str(plan.get("id") or path.name[: -len(".vbrief.json")])
301
+
302
+ ref_repo, number = _extract_issue_ref(data)
303
+ effective_repo = ref_repo or repo
304
+ if number is None or effective_repo is None:
305
+ outcome.skipped_no_ref.append(story_id)
306
+ continue
307
+ key = (effective_repo, number)
308
+ if key in seen_issues:
309
+ continue
310
+ seen_issues.add(key)
311
+
312
+ metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
313
+ swarm = metadata.get("swarm") if isinstance(metadata.get("swarm"), dict) else {}
314
+ desired = compute_desired_labels(
315
+ plan, unresolved_deps=_unresolved_deps(swarm, known_ids)
316
+ )
317
+
318
+ try:
319
+ current = client.fetch_labels(effective_repo, number)
320
+ except ScmLabelError as exc:
321
+ outcome.errors.append((story_id, str(exc)))
322
+ continue
323
+
324
+ current_managed = {name for name in current if name in MANAGED_LABELS}
325
+ add = sorted(desired - current_managed)
326
+ remove = sorted(current_managed - desired)
327
+ change = LabelChange(
328
+ story_id=story_id,
329
+ repo=effective_repo,
330
+ issue_number=number,
331
+ current=sorted(current),
332
+ desired=sorted(desired),
333
+ add=add,
334
+ remove=remove,
335
+ )
336
+
337
+ if not add and not remove:
338
+ outcome.unchanged.append(change)
339
+ continue
340
+ if dry_run:
341
+ outcome.changed.append(change)
342
+ continue
343
+ try:
344
+ client.apply(effective_repo, number, add, remove)
345
+ except ScmLabelError as exc:
346
+ outcome.errors.append((story_id, str(exc)))
347
+ continue
348
+ outcome.changed.append(change)
349
+
350
+ exit_code = 1 if outcome.errors else 0
351
+ return exit_code, outcome
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Rendering + CLI
356
+ # ---------------------------------------------------------------------------
357
+
358
+
359
+ def _render_report(outcome: ReconcileLabelsOutcome) -> str:
360
+ lines: list[str] = ["vBRIEF reconcile labels", ""]
361
+ suffix = " (dry-run)" if outcome.dry_run else ""
362
+
363
+ lines.append(f"Changed{suffix}:")
364
+ if outcome.changed:
365
+ for change in outcome.changed:
366
+ parts: list[str] = []
367
+ if change.add:
368
+ parts.append(f"+{', +'.join(change.add)}")
369
+ if change.remove:
370
+ parts.append(f"-{', -'.join(change.remove)}")
371
+ lines.append(
372
+ f"- #{change.issue_number} ({change.repo}) "
373
+ f"[{change.story_id}]: {'; '.join(parts)}"
374
+ )
375
+ else:
376
+ lines.append("- none")
377
+ lines.append("")
378
+
379
+ lines.append("Unchanged:")
380
+ if outcome.unchanged:
381
+ lines.extend(
382
+ f"- #{c.issue_number} ({c.repo}) [{c.story_id}]" for c in outcome.unchanged
383
+ )
384
+ else:
385
+ lines.append("- none")
386
+
387
+ if outcome.skipped_no_ref:
388
+ lines.append("")
389
+ lines.append("Skipped (no github-issue reference / repo):")
390
+ lines.extend(f"- {story_id}" for story_id in outcome.skipped_no_ref)
391
+
392
+ if outcome.errors:
393
+ lines.append("")
394
+ lines.append("Errors:")
395
+ lines.extend(f"- {story_id}: {message}" for story_id, message in outcome.errors)
396
+
397
+ return "\n".join(lines)
398
+
399
+
400
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
401
+ parser = argparse.ArgumentParser(
402
+ description=(
403
+ "Reconcile SCM labels to mirror canonical vBRIEF state: "
404
+ "status:blocked (blocked / unresolved deps), epic + status:tracker "
405
+ "(kind=epic), rfc (kind=research). Routes through scripts/scm.py "
406
+ "(#1145). Idempotent."
407
+ )
408
+ )
409
+ parser.add_argument(
410
+ "--project-root",
411
+ default=".",
412
+ help="Project root containing vbrief/ (default: current directory).",
413
+ )
414
+ parser.add_argument(
415
+ "--repo",
416
+ default=None,
417
+ help=(
418
+ "Fallback repo slug 'owner/name' used ONLY when a vBRIEF's "
419
+ "github-issue reference URI lacks an owner/repo segment."
420
+ ),
421
+ )
422
+ parser.add_argument(
423
+ "--dry-run",
424
+ action="store_true",
425
+ help="Report which labels WOULD change without mutating any issue.",
426
+ )
427
+ parser.add_argument(
428
+ "--json",
429
+ action="store_true",
430
+ help="Emit a machine-readable JSON summary instead of the text report.",
431
+ )
432
+ return parser.parse_args(argv)
433
+
434
+
435
+ def main(argv: list[str] | None = None) -> int:
436
+ args = _parse_args(sys.argv[1:] if argv is None else argv)
437
+ project_root = Path(args.project_root).resolve()
438
+ exit_code, outcome = reconcile_labels(
439
+ project_root,
440
+ repo=args.repo,
441
+ dry_run=args.dry_run,
442
+ )
443
+ if exit_code == 2:
444
+ if args.json:
445
+ print(json.dumps({"error": "no vbrief/ directory found"}))
446
+ else:
447
+ print(
448
+ f"Error: no vbrief/ directory found under {project_root}",
449
+ file=sys.stderr,
450
+ )
451
+ return 2
452
+ if args.json:
453
+ print(json.dumps(outcome.to_json(), indent=2))
454
+ else:
455
+ print(_render_report(outcome))
456
+ return exit_code
457
+
458
+
459
+ if __name__ == "__main__":
460
+ raise SystemExit(main())