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