@deftai/directive-content 0.59.0 → 0.60.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 (184) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +48 -58
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scm/github.md +20 -2
  10. package/tasks/change.yml +16 -31
  11. package/tasks/ci.yml +8 -0
  12. package/tasks/commit.yml +12 -19
  13. package/tasks/core.yml +10 -0
  14. package/tasks/engine.yml +42 -0
  15. package/tasks/framework.yml +3 -0
  16. package/tasks/install.yml +20 -19
  17. package/tasks/migrate.yml +26 -15
  18. package/tasks/project.yml +16 -0
  19. package/tasks/toolchain.yml +15 -5
  20. package/tasks/vbrief.yml +4 -3
  21. package/tasks/verify.yml +12 -14
  22. package/scripts/_agents_md.py +0 -494
  23. package/scripts/_cache_fetch.py +0 -635
  24. package/scripts/_cache_quota.py +0 -529
  25. package/scripts/_cache_refresh.py +0 -163
  26. package/scripts/_cache_validate.py +0 -209
  27. package/scripts/_content_root.py +0 -42
  28. package/scripts/_doctor_state.py +0 -277
  29. package/scripts/_event_detect.py +0 -305
  30. package/scripts/_events.py +0 -514
  31. package/scripts/_lifecycle_hygiene.py +0 -568
  32. package/scripts/_pathspec.py +0 -91
  33. package/scripts/_policy_show_cli.py +0 -266
  34. package/scripts/_precutover.py +0 -92
  35. package/scripts/_project_context.py +0 -224
  36. package/scripts/_project_definition_io.py +0 -164
  37. package/scripts/_relocate_snapshot.py +0 -209
  38. package/scripts/_relocate_states.py +0 -343
  39. package/scripts/_resolve_preflight_path.py +0 -152
  40. package/scripts/_safe_subprocess.py +0 -167
  41. package/scripts/_session_start_hook.py +0 -205
  42. package/scripts/_sor_gate_diff.py +0 -365
  43. package/scripts/_stdio_utf8.py +0 -59
  44. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  45. package/scripts/_triage_classify_cli.py +0 -122
  46. package/scripts/_triage_queue_cli.py +0 -625
  47. package/scripts/_triage_scope_cli.py +0 -343
  48. package/scripts/_triage_scope_drift_cli.py +0 -121
  49. package/scripts/_triage_scope_ignores.py +0 -286
  50. package/scripts/_triage_scope_milestone.py +0 -432
  51. package/scripts/_triage_scope_mutations.py +0 -337
  52. package/scripts/_triage_scope_renderers.py +0 -207
  53. package/scripts/_triage_smoketest_stages.py +0 -674
  54. package/scripts/_triage_subscribe_cli.py +0 -140
  55. package/scripts/_triage_welcome_cli.py +0 -421
  56. package/scripts/_vbrief_build.py +0 -239
  57. package/scripts/_vbrief_fidelity.py +0 -479
  58. package/scripts/_vbrief_legacy.py +0 -589
  59. package/scripts/_vbrief_reconciliation.py +0 -883
  60. package/scripts/_vbrief_routing.py +0 -277
  61. package/scripts/_vbrief_safety.py +0 -778
  62. package/scripts/_vbrief_sources.py +0 -312
  63. package/scripts/_vbrief_speckit.py +0 -262
  64. package/scripts/_vbrief_story_quality.py +0 -353
  65. package/scripts/_vbrief_validation.py +0 -299
  66. package/scripts/build_dist.py +0 -412
  67. package/scripts/cache.py +0 -1078
  68. package/scripts/cache_scanner.py +0 -745
  69. package/scripts/candidates_log.py +0 -432
  70. package/scripts/capacity_backfill.py +0 -680
  71. package/scripts/capacity_show.py +0 -653
  72. package/scripts/ci_local.py +0 -689
  73. package/scripts/code_structure_validate.py +0 -765
  74. package/scripts/codebase_default_extractor.py +0 -495
  75. package/scripts/codebase_map.py +0 -304
  76. package/scripts/codebase_map_fresh.py +0 -104
  77. package/scripts/codebase_projection_registry.py +0 -94
  78. package/scripts/codebase_provider.py +0 -582
  79. package/scripts/doctor.py +0 -2552
  80. package/scripts/framework_commands.py +0 -505
  81. package/scripts/gh_rest.py +0 -882
  82. package/scripts/github_auth_modes.py +0 -437
  83. package/scripts/github_body.py +0 -292
  84. package/scripts/ip_risk.py +0 -531
  85. package/scripts/issue_emit.py +0 -670
  86. package/scripts/issue_ingest.py +0 -1064
  87. package/scripts/migrate_preflight.py +0 -418
  88. package/scripts/migrate_vbrief.py +0 -2677
  89. package/scripts/monitor_pr.py +0 -401
  90. package/scripts/pack_migrate_lessons.py +0 -336
  91. package/scripts/pack_migrate_patterns.py +0 -254
  92. package/scripts/pack_migrate_rules.py +0 -350
  93. package/scripts/pack_migrate_skills.py +0 -423
  94. package/scripts/pack_migrate_strategies.py +0 -311
  95. package/scripts/pack_migrate_swarm_spec.py +0 -250
  96. package/scripts/pack_render.py +0 -434
  97. package/scripts/packs_slice.py +0 -712
  98. package/scripts/platform_capabilities.py +0 -336
  99. package/scripts/policy.py +0 -2826
  100. package/scripts/policy_set.py +0 -324
  101. package/scripts/pr_check_closing_keywords.py +0 -524
  102. package/scripts/pr_check_protected_issues.py +0 -267
  103. package/scripts/pr_merge_readiness.py +0 -1004
  104. package/scripts/pr_wait_mergeable.py +0 -669
  105. package/scripts/prd_render.py +0 -159
  106. package/scripts/preflight_architecture_sor.py +0 -974
  107. package/scripts/preflight_branch.py +0 -289
  108. package/scripts/preflight_cache.py +0 -974
  109. package/scripts/preflight_gh.py +0 -721
  110. package/scripts/preflight_implementation.py +0 -272
  111. package/scripts/preflight_story_start.py +0 -838
  112. package/scripts/preflight_wip_cap.py +0 -149
  113. package/scripts/probe_session.py +0 -545
  114. package/scripts/project_render.py +0 -293
  115. package/scripts/quarantine_ext.py +0 -237
  116. package/scripts/reconcile_issues.py +0 -1442
  117. package/scripts/refresh-path.ps1 +0 -107
  118. package/scripts/release.py +0 -2030
  119. package/scripts/release_e2e.py +0 -1011
  120. package/scripts/release_publish.py +0 -486
  121. package/scripts/release_rollback.py +0 -980
  122. package/scripts/relocate.py +0 -1034
  123. package/scripts/resolve_changelog_unreleased.py +0 -667
  124. package/scripts/resolve_version.py +0 -490
  125. package/scripts/resume_conditions.py +0 -706
  126. package/scripts/ritual_sentinel.py +0 -609
  127. package/scripts/roadmap_render.py +0 -635
  128. package/scripts/rule_ownership_lint.py +0 -325
  129. package/scripts/scm.py +0 -591
  130. package/scripts/scope_audit_log.py +0 -387
  131. package/scripts/scope_decompose.py +0 -654
  132. package/scripts/scope_demote.py +0 -509
  133. package/scripts/scope_lifecycle.py +0 -1126
  134. package/scripts/scope_undo.py +0 -772
  135. package/scripts/session_start.py +0 -406
  136. package/scripts/setup_ghx.py +0 -339
  137. package/scripts/setup_windows.ps1 +0 -220
  138. package/scripts/slice_audit.py +0 -585
  139. package/scripts/slice_record.py +0 -530
  140. package/scripts/slice_record_existing.py +0 -692
  141. package/scripts/slug_normalize.py +0 -178
  142. package/scripts/spec_render.py +0 -477
  143. package/scripts/spec_validate.py +0 -238
  144. package/scripts/subagent_monitor.py +0 -658
  145. package/scripts/swarm_complete_cohort.py +0 -644
  146. package/scripts/swarm_launch.py +0 -1206
  147. package/scripts/swarm_readiness.py +0 -554
  148. package/scripts/swarm_verify_review_clean.py +0 -438
  149. package/scripts/swarm_worktrees.py +0 -497
  150. package/scripts/toolchain-check.py +0 -52
  151. package/scripts/triage_actions.py +0 -871
  152. package/scripts/triage_bootstrap.py +0 -1153
  153. package/scripts/triage_bulk.py +0 -630
  154. package/scripts/triage_classify.py +0 -932
  155. package/scripts/triage_help.py +0 -1685
  156. package/scripts/triage_queue.py +0 -1944
  157. package/scripts/triage_reconcile.py +0 -581
  158. package/scripts/triage_refresh.py +0 -643
  159. package/scripts/triage_scope.py +0 -999
  160. package/scripts/triage_scope_drift.py +0 -575
  161. package/scripts/triage_smoketest.py +0 -396
  162. package/scripts/triage_subscribe.py +0 -399
  163. package/scripts/triage_summary.py +0 -1011
  164. package/scripts/triage_welcome.py +0 -1178
  165. package/scripts/ts_check_lane.py +0 -86
  166. package/scripts/validate-links.py +0 -64
  167. package/scripts/validate_strategy_output.py +0 -212
  168. package/scripts/vbrief_activate.py +0 -228
  169. package/scripts/vbrief_migrate_conformance.py +0 -368
  170. package/scripts/vbrief_reconcile_graph.py +0 -306
  171. package/scripts/vbrief_reconcile_labels.py +0 -460
  172. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  173. package/scripts/vbrief_validate.py +0 -1144
  174. package/scripts/verify-stubs.py +0 -61
  175. package/scripts/verify_capacity.py +0 -160
  176. package/scripts/verify_encoding.py +0 -699
  177. package/scripts/verify_hooks_installed.py +0 -206
  178. package/scripts/verify_investigation.py +0 -360
  179. package/scripts/verify_judgment_gates.py +0 -827
  180. package/scripts/verify_no_task_runtime.py +0 -171
  181. package/scripts/verify_scm_boundary.py +0 -509
  182. package/scripts/verify_session_ritual.py +0 -389
  183. package/scripts/verify_tools.py +0 -426
  184. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,460 +0,0 @@
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())