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