@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,1442 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- reconcile_issues.py -- Reconcile GitHub issues against vBRIEF references.
4
-
5
- Usage:
6
- uv run python scripts/reconcile_issues.py [options]
7
-
8
- Options:
9
- --vbrief-dir DIR Path to vbrief/ directory
10
- --repo OWNER/REPO GitHub repo
11
- --format json|markdown Output format
12
- --apply-lifecycle-fixes Move non-terminal closed-issue vBRIEFs
13
- to completed/ (idempotent; #734)
14
- --report-unlinked Emit the legacy three-section report
15
- including issues with no vBRIEF (#754)
16
- --max-open-issues N Safety cap for --report-unlinked path
17
- (default 1000) (#754)
18
-
19
- Reads all vBRIEF files in the lifecycle folders (proposed/, pending/, active/,
20
- completed/, cancelled/) and extracts github-issue references from the
21
- ``references`` arrays.
22
-
23
- Default path (#754): produces a two-section report via inverted lookup --
24
- the scanner extracts the set of issue numbers referenced by vBRIEFs and
25
- queries just those issues' states via batched ``gh api graphql`` (aliased
26
- node queries). Cost scales by O(vBRIEF-referenced-issue-count), bounded
27
- by the repo's vBRIEF count rather than total open-issue count. Sections:
28
-
29
- (a) linked -- referenced issues with state ``OPEN``
30
- (c) no_open_issue -- referenced issues with state ``CLOSED`` /
31
- ``NOT_FOUND`` (the apply-mode candidates)
32
-
33
- The legacy section (b) ``unlinked`` (open issues with NO matching vBRIEF)
34
- is NOT emitted in the default path because it requires fetching every
35
- open issue in the repo -- which scales by O(repo-open-issue-count) and
36
- caused #754's false-positive flood on a 225-open-issue repo (the prior
37
- 200-issue cap silently treated the tail as closed). The legacy three-
38
- section report is available via ``--report-unlinked`` with a
39
- ``--max-open-issues`` safety cap.
40
-
41
- When ``--apply-lifecycle-fixes`` (#734) is passed, Section (c) entries that
42
- are not already in a terminal lifecycle folder (``completed/`` or
43
- ``cancelled/``) are auto-resolved: the vBRIEF JSON gains
44
- ``plan.status = "completed"``, ``vBRIEFInfo.updated`` is stamped with the
45
- current UTC ISO timestamp, and the file is ``git mv``\'d (or filesystem-
46
- moved) into ``completed/``. The flag is idempotent: a second run is a
47
- no-op once every closed-issue vBRIEF lives in a terminal lifecycle folder.
48
- Reverse mismatches (terminal vBRIEF whose issue was reopened) are
49
- report-only -- never auto-reverse-moved.
50
-
51
- Exit codes:
52
- 0 -- report generated successfully (or apply-mode clean / all moves OK)
53
- 1 -- error (missing dependencies, API failure, partial apply failure,
54
- --report-unlinked over the --max-open-issues cap)
55
- 2 -- usage / configuration error
56
-
57
- Story #322, RFC #309. Apply-mode: #734. Inverted-lookup scaling: #754.
58
- """
59
-
60
- import datetime as _dt
61
- import json
62
- import re
63
- import shutil
64
- import subprocess
65
- import sys
66
- from pathlib import Path
67
-
68
- # Make sibling ``_stdio_utf8`` / ``_project_context`` importable when run
69
- # as ``__main__`` and when imported by tests that preload sys.path.
70
- sys.path.insert(0, str(Path(__file__).resolve().parent))
71
-
72
- from _project_context import resolve_project_repo, resolve_project_root # noqa: E402
73
- from _stdio_utf8 import reconfigure_stdio # noqa: E402
74
-
75
- reconfigure_stdio()
76
-
77
- # ---------------------------------------------------------------------------
78
- # Constants
79
- # ---------------------------------------------------------------------------
80
-
81
- LIFECYCLE_FOLDERS = ("proposed", "pending", "active", "completed", "cancelled")
82
- TERMINAL_LIFECYCLE_FOLDERS: frozenset[str] = frozenset(
83
- {"completed", "cancelled"}
84
- )
85
-
86
- ISSUE_URL_PATTERN = re.compile(
87
- r"https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/issues/(?P<number>\d+)"
88
- )
89
- ISSUE_ID_PATTERN = re.compile(r"^#(?P<number>\d+)$")
90
-
91
- # Reference-type strings that identify a GitHub issue origin. The migrator
92
- # emits the canonical v0.6 ``x-vbrief/github-issue`` type (#613); legacy
93
- # vBRIEFs produced by earlier migrator runs (or hand-authored pre-v0.20
94
- # fixtures) use the bare ``github-issue`` string. Both shapes are accepted
95
- # here so the reconciler stays idempotent across the transition.
96
- GITHUB_ISSUE_REF_TYPES: frozenset[str] = frozenset(
97
- {"github-issue", "x-vbrief/github-issue"}
98
- )
99
-
100
- # #1290: GitHub ``stateReason`` values that route a CLOSED issue's vBRIEF
101
- # to ``cancelled/`` rather than ``completed/``. ``COMPLETED`` (and a null
102
- # reason / ``NOT_FOUND``) route to ``completed/`` -- the pre-#1290 default.
103
- CANCELLED_STATE_REASONS: frozenset[str] = frozenset({"NOT_PLANNED", "DUPLICATE"})
104
-
105
-
106
- class IssueState(str):
107
- """A ``str`` subclass carrying a GitHub issue's state plus stateReason.
108
-
109
- Phase A of #1290 extends ``fetch_issue_states`` to also fetch each
110
- issue's ``stateReason`` so apply-mode can route CLOSED+NOT_PLANNED /
111
- CLOSED+DUPLICATE to ``cancelled/`` while CLOSED+COMPLETED stays in
112
- ``completed/``. To avoid breaking the many existing callers (and
113
- tests) that compare the return value directly to the bare strings
114
- ``"OPEN"`` / ``"CLOSED"`` / ``"NOT_FOUND"`` -- including
115
- ``scripts/release.py::check_vbrief_lifecycle_sync`` -- the value is a
116
- ``str`` subclass: it still ``==`` the bare state string, so legacy
117
- code keeps working unchanged, while new code reads ``.state_reason``.
118
- """
119
-
120
- state_reason: str | None
121
-
122
- def __new__(cls, state: str, state_reason: str | None = None) -> "IssueState":
123
- obj = super().__new__(cls, state)
124
- obj.state_reason = state_reason
125
- return obj
126
-
127
-
128
- def state_reason_of(value: object) -> str | None:
129
- """Return the ``stateReason`` carried by a state-map value, or None.
130
-
131
- Thin accessor so callers that hold a state-map value (which may be a
132
- plain ``str`` from a legacy/monkeypatched fetch or an ``IssueState``
133
- from the real fetch) can read the reason without an ``isinstance``
134
- dance. Returns ``None`` for plain strings / missing values (#1290).
135
- """
136
- return getattr(value, "state_reason", None)
137
-
138
-
139
- def is_terminal_lifecycle_path(rel_path: str) -> bool:
140
- """Return True when a vBRIEF relative path is already terminal."""
141
- folder, sep, _filename = rel_path.partition("/")
142
- return sep == "/" and folder in TERMINAL_LIFECYCLE_FOLDERS
143
-
144
-
145
- # ---------------------------------------------------------------------------
146
- # vBRIEF scanning
147
- # ---------------------------------------------------------------------------
148
-
149
-
150
- def extract_references_from_vbrief(data: dict) -> list[dict]:
151
- """Extract all references from a vBRIEF data structure.
152
-
153
- Walks plan.references and each item's references recursively.
154
- """
155
- refs: list[dict] = []
156
- plan = data.get("plan", {})
157
-
158
- # Top-level plan references
159
- for ref in plan.get("references", []):
160
- if isinstance(ref, dict):
161
- refs.append(ref)
162
-
163
- # Item-level references (and nested subItems). Every container access
164
- # uses ``... or []`` rather than ``.get(key, [])``: a key present with an
165
- # explicit JSON ``null`` value returns ``None`` from ``.get(key, [])``
166
- # (the default only fires for ABSENT keys), and ``for x in None`` raises
167
- # ``TypeError`` (#924).
168
- def _walk_items(items: list | None) -> None:
169
- for item in items or []:
170
- if not isinstance(item, dict):
171
- continue
172
- for ref in item.get("references") or []:
173
- if isinstance(ref, dict):
174
- refs.append(ref)
175
- _walk_items(item.get("subItems") or [])
176
- _walk_items(item.get("items") or [])
177
-
178
- _walk_items(plan.get("items") or [])
179
- return refs
180
-
181
-
182
- def parse_issue_number(ref: dict) -> int | None:
183
- """Extract a GitHub issue number from a vBRIEF reference dict.
184
-
185
- Accepts both the canonical v0.6 shape ``{uri, type, title}`` (#613) and
186
- the legacy pre-v0.20 shapes ``{type, url}`` / ``{type, id}`` so mixed-
187
- shape trees (projects partway through the migrator flip) reconcile
188
- cleanly. The URL-bearing keys (``uri`` and ``url``) are searched first
189
- because they disambiguate the owner/repo; ``id`` is the last-resort
190
- fallback used by the legacy migrator output.
191
- """
192
- for key in ("uri", "url"):
193
- value = ref.get(key, "")
194
- if isinstance(value, str) and value:
195
- m = ISSUE_URL_PATTERN.search(value)
196
- if m:
197
- return int(m.group("number"))
198
-
199
- ref_id = ref.get("id", "")
200
- if isinstance(ref_id, str):
201
- m = ISSUE_ID_PATTERN.match(ref_id)
202
- if m:
203
- return int(m.group("number"))
204
- return None
205
-
206
-
207
- def scan_vbrief_dir(vbrief_dir: Path) -> dict[int, list[str]]:
208
- """Scan all lifecycle folders for vBRIEF files and extract issue references.
209
-
210
- Returns:
211
- Mapping of issue_number -> list of vBRIEF file paths (relative to vbrief_dir).
212
- """
213
- issue_to_vbriefs: dict[int, list[str]] = {}
214
-
215
- for folder in LIFECYCLE_FOLDERS:
216
- folder_path = vbrief_dir / folder
217
- if not folder_path.is_dir():
218
- continue
219
- for vbrief_file in sorted(folder_path.glob("*.vbrief.json")):
220
- try:
221
- data = json.loads(vbrief_file.read_text(encoding="utf-8"))
222
- except (json.JSONDecodeError, OSError):
223
- continue
224
-
225
- refs = extract_references_from_vbrief(data)
226
- rel_path = f"{folder}/{vbrief_file.name}"
227
- for ref in refs:
228
- # #613: accept both the canonical v0.6 type
229
- # (``x-vbrief/github-issue``) and the legacy bare
230
- # ``github-issue`` so scans over partially-migrated
231
- # trees find every GitHub-issue origin.
232
- if ref.get("type") not in GITHUB_ISSUE_REF_TYPES:
233
- continue
234
- num = parse_issue_number(ref)
235
- if num is not None:
236
- issue_to_vbriefs.setdefault(num, []).append(rel_path)
237
-
238
- return issue_to_vbriefs
239
-
240
-
241
- # ---------------------------------------------------------------------------
242
- # GitHub issue fetching
243
- # ---------------------------------------------------------------------------
244
-
245
-
246
- ISSUE_FETCH_LIMIT = 1000
247
-
248
- # #754: GraphQL aliased-node batch size for ``fetch_issue_states``. GitHub's
249
- # GraphQL ceiling is ~500 nodes per query; 200 keeps each query well under
250
- # the limit and bounds query body size for repos with very large vBRIEF
251
- # counts.
252
- GRAPHQL_BATCH_SIZE = 200
253
-
254
- # #754: paginated all-open-issues fetch limit for the ``--report-unlinked``
255
- # opt-in path. ``gh issue list --limit 0`` fetches every open issue via
256
- # native pagination (no per_page cap). Default operator-facing safety cap
257
- # is 1000 -- raised via ``--max-open-issues N`` when the operator has
258
- # acknowledged the cost.
259
- DEFAULT_MAX_OPEN_ISSUES = 1000
260
-
261
-
262
- def fetch_open_issues(repo: str, cwd: Path | None = None) -> list[dict] | None:
263
- """Fetch open issues from GitHub using gh CLI.
264
-
265
- Retained for the opt-in ``--report-unlinked`` path; the release-pipeline
266
- gate uses ``fetch_issue_states`` for inverted-lookup scaling (#754).
267
-
268
- ``cwd`` is passed to ``subprocess.run`` so that ``gh`` resolves its
269
- auth / config from the consumer project's directory rather than
270
- whichever directory the included Taskfile happens to be in (#538).
271
- Explicit ``--repo`` already targets the correct repository; ``cwd``
272
- is a belt-and-suspenders guard for any future path-sensitive checks.
273
-
274
- Returns a list of dicts with keys: number, title, labels, url.
275
- Returns None on error (gh not found, timeout, API failure, parse error).
276
- """
277
- try:
278
- result = subprocess.run(
279
- [
280
- "gh", "issue", "list",
281
- "--repo", repo,
282
- "--state", "open",
283
- "--limit", str(ISSUE_FETCH_LIMIT),
284
- "--json", "number,title,labels,url",
285
- ],
286
- capture_output=True,
287
- text=True,
288
- timeout=60,
289
- cwd=str(cwd) if cwd is not None else None,
290
- )
291
- except FileNotFoundError:
292
- print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
293
- return None
294
- except subprocess.TimeoutExpired:
295
- print("Error: gh CLI timed out.", file=sys.stderr)
296
- return None
297
-
298
- if result.returncode != 0:
299
- print(f"Error: gh CLI failed: {result.stderr.strip()}", file=sys.stderr)
300
- return None
301
-
302
- try:
303
- issues: list[dict] = json.loads(result.stdout)
304
- except json.JSONDecodeError:
305
- print("Error: failed to parse gh CLI output.", file=sys.stderr)
306
- return None
307
-
308
- if len(issues) >= ISSUE_FETCH_LIMIT:
309
- print(
310
- f"Warning: fetched {len(issues)} issues (limit {ISSUE_FETCH_LIMIT}). "
311
- "Report may be incomplete.",
312
- file=sys.stderr,
313
- )
314
-
315
- return issues
316
-
317
-
318
- def fetch_all_open_issues(
319
- repo: str, cwd: Path | None = None
320
- ) -> list[dict] | None:
321
- """Fetch ALL open issues from GitHub using gh CLI native pagination (#754).
322
-
323
- Used by the ``--report-unlinked`` opt-in path. Invokes
324
- ``gh issue list --limit 0`` which paginates internally and returns
325
- every open issue regardless of count. The caller is responsible for
326
- enforcing ``--max-open-issues`` after this returns.
327
-
328
- Returns a list of dicts with keys: number, title, labels, url.
329
- Returns None on error (gh not found, timeout, API failure, parse error).
330
- """
331
- try:
332
- result = subprocess.run(
333
- [
334
- "gh", "issue", "list",
335
- "--repo", repo,
336
- "--state", "open",
337
- # ``--limit 0`` opts into gh's native unlimited pagination.
338
- "--limit", "0",
339
- "--json", "number,title,labels,url",
340
- ],
341
- capture_output=True,
342
- text=True,
343
- # 5 min ceiling -- a properly-paginated fetch on a 10k-open
344
- # repo completes inside this budget; anything beyond is a
345
- # real auth / network failure to surface cleanly.
346
- timeout=300,
347
- cwd=str(cwd) if cwd is not None else None,
348
- )
349
- except FileNotFoundError:
350
- print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
351
- return None
352
- except subprocess.TimeoutExpired:
353
- print("Error: gh CLI timed out.", file=sys.stderr)
354
- return None
355
-
356
- if result.returncode != 0:
357
- print(f"Error: gh CLI failed: {result.stderr.strip()}", file=sys.stderr)
358
- return None
359
-
360
- try:
361
- issues: list[dict] = json.loads(result.stdout)
362
- except json.JSONDecodeError:
363
- print("Error: failed to parse gh CLI output.", file=sys.stderr)
364
- return None
365
-
366
- return issues
367
-
368
-
369
- def _split_repo_slug(repo: str) -> tuple[str, str] | None:
370
- """Split ``OWNER/REPO`` into ``(owner, repo)``; None on malformed input."""
371
- parts = repo.split("/", 1)
372
- if len(parts) != 2 or not parts[0] or not parts[1]:
373
- return None
374
- return parts[0], parts[1]
375
-
376
-
377
- def fetch_issue_states(
378
- repo: str,
379
- issue_numbers: set[int],
380
- cwd: Path | None = None,
381
- *,
382
- batch_size: int = GRAPHQL_BATCH_SIZE,
383
- ) -> dict[int, IssueState] | None:
384
- """Fetch GitHub issue states via batched ``gh api graphql`` (#754).
385
-
386
- Inverts the lookup direction relative to ``fetch_open_issues``:
387
- instead of fetching every open issue in the repo and filtering for
388
- the vBRIEF-referenced subset, this helper takes the subset directly
389
- (``issue_numbers``) and queries the state of just those issues. The
390
- cost therefore scales by ``O(len(issue_numbers))`` -- the
391
- vBRIEF-referenced-issue-count -- rather than
392
- ``O(repo-open-issue-count)``.
393
-
394
- Implementation: builds a GraphQL query with aliased nodes
395
- (``i100: issue(number: 100) { state }``), batched at ``batch_size``
396
- nodes per query (default 200; safe under GitHub's ~500 ceiling). One
397
- ``gh api graphql`` invocation per batch. Issues that don't exist in
398
- the repo are returned as ``"NOT_FOUND"`` (the corresponding aliased
399
- node is null in the GraphQL response).
400
-
401
- ``cwd`` is forwarded to ``subprocess.run`` so ``gh`` resolves its
402
- auth / config from the consumer project's directory (#538
403
- belt-and-suspenders).
404
-
405
- Returns a dict mapping issue_number -> ``IssueState`` (a ``str``
406
- subclass equal to ``"OPEN"`` / ``"CLOSED"`` / ``"NOT_FOUND"`` that
407
- additionally carries the GitHub ``stateReason`` via
408
- ``.state_reason``) when every batch resolved cleanly, ``None`` on
409
- subprocess error, parse error, or non-zero exit (mirrors
410
- ``fetch_open_issues``). An empty ``issue_numbers`` set returns an
411
- empty dict (no subprocess call). #1290 added the ``stateReason``
412
- selection so apply-mode can route NOT_PLANNED / DUPLICATE closures
413
- to ``cancelled/``; the ``str`` subclass keeps every existing caller
414
- (and the bare-string equality tests) working unchanged.
415
-
416
- Refs #754 (inverted-lookup gate fix), #1290 (stateReason); see also
417
- ``reconcile()`` and ``scripts/release.py::check_vbrief_lifecycle_sync``.
418
- """
419
- if not issue_numbers:
420
- return {}
421
- parsed = _split_repo_slug(repo)
422
- if parsed is None:
423
- print(
424
- f"Error: invalid repo slug {repo!r}; expected OWNER/REPO.",
425
- file=sys.stderr,
426
- )
427
- return None
428
- owner, name = parsed
429
-
430
- sorted_numbers = sorted(issue_numbers)
431
- states: dict[int, IssueState] = {}
432
-
433
- for start in range(0, len(sorted_numbers), batch_size):
434
- batch = sorted_numbers[start : start + batch_size]
435
- # Aliased-node block: each issue gets a unique alias (``i<N>``)
436
- # so the GraphQL response carries every state in a single query.
437
- # #1290: also select ``stateReason`` so apply-mode can route
438
- # NOT_PLANNED / DUPLICATE closures to ``cancelled/``.
439
- aliases = "\n ".join(
440
- f"i{n}: issue(number: {n}) {{ state stateReason }}" for n in batch
441
- )
442
- query = (
443
- "query {\n"
444
- f' repository(owner: "{owner}", name: "{name}") {{\n'
445
- f" {aliases}\n"
446
- " }\n"
447
- "}\n"
448
- )
449
- try:
450
- result = subprocess.run(
451
- ["gh", "api", "graphql", "-f", f"query={query}"],
452
- capture_output=True,
453
- text=True,
454
- timeout=60,
455
- cwd=str(cwd) if cwd is not None else None,
456
- )
457
- except FileNotFoundError:
458
- print(
459
- "Error: gh CLI not found. Install GitHub CLI.",
460
- file=sys.stderr,
461
- )
462
- return None
463
- except subprocess.TimeoutExpired:
464
- print("Error: gh CLI timed out.", file=sys.stderr)
465
- return None
466
-
467
- # Tolerate partial GraphQL errors: when an issue number actually
468
- # references a PR (or a deleted/transferred record) GitHub emits a
469
- # top-level ``errors[*]`` entry AND gh exits non-zero, but the
470
- # response ``data`` field is still populated (just with ``null``
471
- # for the offending alias). Treat that as a soft failure so the
472
- # caller can classify the missing aliases as NOT_FOUND. A truly
473
- # fatal error (auth, network, malformed query) leaves ``stdout``
474
- # empty / non-JSON and is still surfaced as ``None``.
475
- try:
476
- payload = json.loads(result.stdout) if result.stdout else None
477
- except json.JSONDecodeError:
478
- payload = None
479
-
480
- if result.returncode != 0:
481
- if payload is None or not isinstance(payload.get("data"), dict):
482
- print(
483
- f"Error: gh CLI failed: {result.stderr.strip()}",
484
- file=sys.stderr,
485
- )
486
- return None
487
- # Soft-failure path: surface the GraphQL errors as a single
488
- # warning line so operators see the partial-resolve trace,
489
- # then continue with whatever ``data`` came back.
490
- print(
491
- "Warning: gh GraphQL returned partial errors (likely PR "
492
- "numbers referenced as issues): "
493
- f"{result.stderr.strip().splitlines()[0] if result.stderr else ''}",
494
- file=sys.stderr,
495
- )
496
-
497
- if payload is None:
498
- print(
499
- "Error: failed to parse gh CLI graphql output.",
500
- file=sys.stderr,
501
- )
502
- return None
503
-
504
- repo_data = (payload.get("data") or {}).get("repository")
505
- if not isinstance(repo_data, dict):
506
- print(
507
- "Error: gh CLI graphql response missing repository payload.",
508
- file=sys.stderr,
509
- )
510
- return None
511
-
512
- for n in batch:
513
- node = repo_data.get(f"i{n}")
514
- if isinstance(node, dict) and isinstance(node.get("state"), str):
515
- reason = node.get("stateReason")
516
- states[n] = IssueState(
517
- node["state"],
518
- reason if isinstance(reason, str) else None,
519
- )
520
- else:
521
- # GraphQL returns null for non-existent issues; map to a
522
- # sentinel the caller can detect.
523
- states[n] = IssueState("NOT_FOUND", None)
524
-
525
- return states
526
-
527
-
528
- # ---------------------------------------------------------------------------
529
- # Reconciliation
530
- # ---------------------------------------------------------------------------
531
-
532
-
533
- def reconcile(
534
- issue_to_vbriefs: dict[int, list[str]],
535
- issue_state_map: dict[int, str],
536
- ) -> dict:
537
- """Inverted-lookup reconciliation report (default path; #754).
538
-
539
- Classifies vBRIEF-referenced issues using the state map produced by
540
- ``fetch_issue_states``. Cost scales by
541
- ``O(len(issue_to_vbriefs))`` -- bounded by the repo's vBRIEF count
542
- rather than total open-issue count.
543
-
544
- Returns a dict with two sections:
545
- linked -- referenced issues whose state is ``OPEN``
546
- no_open_issue -- referenced issues whose state is ``CLOSED`` /
547
- ``NOT_FOUND`` / unknown (treated as the
548
- apply-mode candidates)
549
-
550
- The legacy ``unlinked`` bucket (open issues with NO matching vBRIEF)
551
- is intentionally absent: it requires fetching every open issue in
552
- the repo, which is the failure mode #754 retired. The legacy
553
- three-section report is available via ``reconcile_with_unlinked``
554
- (surfaced through the ``--report-unlinked`` CLI flag).
555
- """
556
- linked: list[dict] = []
557
- no_open_issue: list[dict] = []
558
-
559
- for num in sorted(issue_to_vbriefs):
560
- state = issue_state_map.get(num, "NOT_FOUND")
561
- vbrief_files = issue_to_vbriefs[num]
562
- if state == "OPEN":
563
- linked.append({
564
- "issue_number": num,
565
- "vbrief_files": vbrief_files,
566
- })
567
- else:
568
- note = (
569
- "Issue is closed"
570
- if state == "CLOSED"
571
- else "Issue is closed or does not exist"
572
- )
573
- # #1290: surface state + stateReason so apply-mode can route
574
- # CLOSED+NOT_PLANNED / CLOSED+DUPLICATE to cancelled/.
575
- no_open_issue.append({
576
- "issue_number": num,
577
- "vbrief_files": vbrief_files,
578
- "note": note,
579
- "state": str(state),
580
- "state_reason": state_reason_of(state),
581
- })
582
-
583
- return {
584
- "linked": linked,
585
- "no_open_issue": no_open_issue,
586
- "summary": {
587
- "linked_count": len(linked),
588
- "vbriefs_no_open_issue_count": len(no_open_issue),
589
- },
590
- }
591
-
592
-
593
- def reconcile_with_unlinked(
594
- issue_to_vbriefs: dict[int, list[str]],
595
- open_issues: list[dict],
596
- ) -> dict:
597
- """Legacy three-section reconciliation including the ``unlinked`` bucket.
598
-
599
- Surfaced via the ``--report-unlinked`` opt-in CLI flag (#754); the
600
- release-pipeline gate uses the inverted-lookup ``reconcile`` instead.
601
-
602
- Returns a dict with three sections:
603
- linked -- open issues with matching vBRIEF provenance
604
- unlinked -- open issues with NO matching vBRIEF
605
- no_open_issue -- vBRIEF references with no matching open issue
606
- """
607
- open_issue_numbers = {i["number"] for i in open_issues}
608
-
609
- linked = []
610
- unlinked = []
611
- no_open_issue = []
612
-
613
- # Classify open issues
614
- for issue in sorted(open_issues, key=lambda i: i["number"]):
615
- num = issue["number"]
616
- if num in issue_to_vbriefs:
617
- linked.append({
618
- "issue_number": num,
619
- "title": issue.get("title", ""),
620
- "url": issue.get("url", ""),
621
- "vbrief_files": issue_to_vbriefs[num],
622
- })
623
- else:
624
- unlinked.append({
625
- "issue_number": num,
626
- "title": issue.get("title", ""),
627
- "url": issue.get("url", ""),
628
- })
629
-
630
- # vBRIEF references with no open issue
631
- for num, vbrief_files in sorted(issue_to_vbriefs.items()):
632
- if num not in open_issue_numbers:
633
- no_open_issue.append({
634
- "issue_number": num,
635
- "vbrief_files": vbrief_files,
636
- "note": "Issue is closed or does not exist",
637
- })
638
-
639
- return {
640
- "linked": linked,
641
- "unlinked": unlinked,
642
- "no_open_issue": no_open_issue,
643
- "summary": {
644
- "total_open_issues": len(open_issues),
645
- "linked_count": len(linked),
646
- "unlinked_count": len(unlinked),
647
- "vbriefs_no_open_issue_count": len(no_open_issue),
648
- },
649
- }
650
-
651
-
652
- # ---------------------------------------------------------------------------
653
- # Lifecycle anchor resolution (#1290 Phase B -- Axis B primary-reference filter)
654
- # ---------------------------------------------------------------------------
655
-
656
-
657
- def _parse_issue_ref_string(raw: object) -> int | None:
658
- """Parse a bare ``#N`` id or a full issue URL into an issue number.
659
-
660
- Shared by ``parse_plan_ref``, ``parse_parent_issue`` and
661
- ``parse_decomposition_origin`` (#1290 / #1319). Returns ``None`` for
662
- non-strings, empty strings, or strings that match neither shape.
663
- """
664
- if not isinstance(raw, str):
665
- return None
666
- candidate = raw.strip()
667
- m = ISSUE_ID_PATTERN.match(candidate)
668
- if m:
669
- return int(m.group("number"))
670
- m = ISSUE_URL_PATTERN.search(candidate)
671
- if m:
672
- return int(m.group("number"))
673
- return None
674
-
675
-
676
- def _x_tracking(data: dict) -> dict:
677
- """Return the ``metadata.x-tracking`` dict for a vBRIEF, or ``{}`` (#1319).
678
-
679
- Decomposition children carry their tracking provenance under
680
- ``plan.metadata.x-tracking`` (the observed shape); a top-level
681
- ``metadata.x-tracking`` is also tolerated for robustness. Always
682
- returns a dict so callers can ``.get(...)`` without guards.
683
- """
684
- for container in (data.get("plan"), data):
685
- if not isinstance(container, dict):
686
- continue
687
- meta = container.get("metadata")
688
- if not isinstance(meta, dict):
689
- continue
690
- xt = meta.get("x-tracking")
691
- if isinstance(xt, dict):
692
- return xt
693
- return {}
694
-
695
-
696
- def parse_plan_ref(data: dict) -> int | None:
697
- """Extract the canonical issue number from ``plan.planRef`` (#1290).
698
-
699
- ``planRef`` is the vBRIEF's own primary issue (e.g. ``"#1290"``). It
700
- is the canonical lifecycle anchor: a vBRIEF that merely *references*
701
- an unrelated closed umbrella in ``plan.references[]`` must NOT be
702
- dragged into that umbrella's terminal state. Accepts both the bare
703
- ``#N`` shape and a full issue URL. Returns ``None`` when the field is
704
- absent or unparseable, so callers can fall back to ``references[]``.
705
- """
706
- plan = data.get("plan", {})
707
- if not isinstance(plan, dict):
708
- return None
709
- return _parse_issue_ref_string(plan.get("planRef"))
710
-
711
-
712
- def parse_parent_issue(data: dict) -> int | None:
713
- """Extract the vBRIEF's own issue from ``x-tracking.parent_issue`` (#1319).
714
-
715
- Decomposition children (carved from an umbrella via the decompose
716
- skill) record their OWN primary issue under
717
- ``metadata.x-tracking.parent_issue`` even when ``plan.planRef`` is
718
- absent. This is the canonical lifecycle anchor for those children:
719
- it is the issue whose closure means the child's work is done, NOT
720
- the umbrella it was carved from. Returns ``None`` when absent or
721
- unparseable.
722
- """
723
- return _parse_issue_ref_string(_x_tracking(data).get("parent_issue"))
724
-
725
-
726
- def parse_decomposition_origin(data: dict) -> int | None:
727
- """Extract the umbrella issue from ``x-tracking.decomposition_origin`` (#1319).
728
-
729
- ``decomposition_origin`` is the (often closed) umbrella issue a child
730
- vBRIEF was carved out of. Its closure is NOT a completion signal for
731
- the child, so the references fallback in ``resolve_lifecycle_anchor``
732
- excludes it. Returns ``None`` when absent or unparseable.
733
- """
734
- return _parse_issue_ref_string(_x_tracking(data).get("decomposition_origin"))
735
-
736
-
737
- def resolve_lifecycle_anchor(data: dict) -> tuple[int | None, str]:
738
- """Resolve a vBRIEF's canonical lifecycle anchor (#1290 Phase B / #1319).
739
-
740
- Returns ``(issue_number, axis)`` where ``axis`` is one of:
741
- - ``"planRef"`` -- ``plan.planRef`` resolved to an issue number.
742
- - ``"parent_issue"`` -- planRef absent; resolved the child's own
743
- issue from ``x-tracking.parent_issue``.
744
- - ``"references"`` -- both absent; fell back to the first
745
- github-issue entry in ``references[]`` that
746
- is NOT the decomposition_origin umbrella.
747
- - ``"none"`` -- nothing yielded a github-issue number.
748
-
749
- The Axis B fix (#1290): ``plan.planRef`` is consulted FIRST so an
750
- umbrella close does not false-positive across a cohort whose own
751
- planRef issues are still open.
752
-
753
- The #1319 hardening: decomposition children carved from an umbrella
754
- frequently lack ``plan.planRef`` but DO record their own primary
755
- issue under ``x-tracking.parent_issue``. That is consulted next, so a
756
- closed umbrella never drags a child whose own issue is still open.
757
- The ``references[]`` fallback additionally EXCLUDES the
758
- ``x-tracking.decomposition_origin`` umbrella, so the closure of the
759
- parent the child was carved from can never -- on its own -- be read
760
- as the child's completion signal (the #742 / #1283 / #1284 / #1285 /
761
- #1291 recurrence).
762
- """
763
- num = parse_plan_ref(data)
764
- if num is not None:
765
- return num, "planRef"
766
- num = parse_parent_issue(data)
767
- if num is not None:
768
- return num, "parent_issue"
769
- decomposition_origin = parse_decomposition_origin(data)
770
- for ref in extract_references_from_vbrief(data):
771
- if ref.get("type") not in GITHUB_ISSUE_REF_TYPES:
772
- continue
773
- num = parse_issue_number(ref)
774
- if num is None:
775
- continue
776
- if decomposition_origin is not None and num == decomposition_origin:
777
- # The umbrella the child was carved from is not a completion
778
- # signal for the child (#1319). Skip it as an anchor candidate.
779
- continue
780
- return num, "references"
781
- return None, "none"
782
-
783
-
784
- def scan_lifecycle_anchors(vbrief_dir: Path) -> list[dict]:
785
- """Resolve the canonical lifecycle anchor for every vBRIEF (#1290).
786
-
787
- Unlike ``scan_vbrief_dir`` (which maps each issue number to ALL the
788
- vBRIEFs that reference it, for the human report), this is vBRIEF-
789
- centric: each vBRIEF resolves to exactly one canonical anchor via
790
- ``resolve_lifecycle_anchor``. Returns a list of dicts with keys
791
- ``rel_path``, ``issue_number`` (``int`` or ``None``), and ``axis``.
792
- """
793
- anchors: list[dict] = []
794
- for folder in LIFECYCLE_FOLDERS:
795
- folder_path = vbrief_dir / folder
796
- if not folder_path.is_dir():
797
- continue
798
- for vbrief_file in sorted(folder_path.glob("*.vbrief.json")):
799
- try:
800
- data = json.loads(vbrief_file.read_text(encoding="utf-8"))
801
- except (json.JSONDecodeError, OSError):
802
- continue
803
- num, axis = resolve_lifecycle_anchor(data)
804
- anchors.append({
805
- "rel_path": f"{folder}/{vbrief_file.name}",
806
- "issue_number": num,
807
- "axis": axis,
808
- })
809
- return anchors
810
-
811
-
812
- def build_lifecycle_report(
813
- anchors: list[dict],
814
- issue_state_map: dict[int, str],
815
- *,
816
- log: bool = True,
817
- ) -> dict:
818
- """Build the apply-mode report from canonical anchors (#1290 Phase B).
819
-
820
- Each vBRIEF is classified by its OWN canonical anchor's state rather
821
- than by every issue it references. Emits a structured per-vBRIEF log
822
- line naming the resolved axis so a recovery audit can confirm the
823
- reconciler routed off the correct anchor. Returns the same two-section
824
- shape as ``reconcile`` (``linked`` / ``no_open_issue`` / ``summary``),
825
- with each ``no_open_issue`` entry carrying ``state`` + ``state_reason``
826
- for apply-mode routing.
827
- """
828
- linked: list[dict] = []
829
- no_open_issue: list[dict] = []
830
-
831
- for anchor in anchors:
832
- rel = anchor["rel_path"]
833
- num = anchor["issue_number"]
834
- axis = anchor["axis"]
835
- if num is None:
836
- if log:
837
- print(
838
- f"[lifecycle-resolve] vbrief={rel} axis=none "
839
- "anchor=none state=n/a stateReason=n/a",
840
- file=sys.stderr,
841
- )
842
- continue
843
- value = issue_state_map.get(num)
844
- state = str(value) if value is not None else "NOT_FOUND"
845
- reason = state_reason_of(value)
846
- if log:
847
- print(
848
- f"[lifecycle-resolve] vbrief={rel} axis={axis} "
849
- f"anchor=#{num} state={state} stateReason={reason}",
850
- file=sys.stderr,
851
- )
852
- if state == "OPEN":
853
- linked.append({"issue_number": num, "vbrief_files": [rel]})
854
- else:
855
- note = (
856
- "Issue is closed"
857
- if state == "CLOSED"
858
- else "Issue is closed or does not exist"
859
- )
860
- no_open_issue.append({
861
- "issue_number": num,
862
- "vbrief_files": [rel],
863
- "note": note,
864
- "state": state,
865
- "state_reason": reason,
866
- })
867
-
868
- return {
869
- "linked": linked,
870
- "no_open_issue": no_open_issue,
871
- "summary": {
872
- "linked_count": len(linked),
873
- "vbriefs_no_open_issue_count": len(no_open_issue),
874
- },
875
- }
876
-
877
-
878
- # ---------------------------------------------------------------------------
879
- # Output formatting
880
- # ---------------------------------------------------------------------------
881
-
882
-
883
- def format_json(report: dict) -> str:
884
- """Format report as JSON."""
885
- return json.dumps(report, indent=2, ensure_ascii=False)
886
-
887
-
888
- def format_markdown(report: dict) -> str:
889
- """Format report as Markdown.
890
-
891
- Handles both the inverted-lookup shape (default path; #754 -- two
892
- sections, no ``unlinked`` bucket) and the legacy three-section shape
893
- surfaced via ``--report-unlinked``. Section (b) is omitted when the
894
- report lacks an ``unlinked`` key.
895
- """
896
- lines: list[str] = []
897
- summary = report["summary"]
898
- has_unlinked = "unlinked" in report
899
-
900
- lines.append("# Issue Reconciliation Report")
901
- lines.append("")
902
- if has_unlinked:
903
- lines.append(f"- **Open issues**: {summary['total_open_issues']}")
904
- lines.append(f"- **Linked** (vBRIEF provenance): {summary['linked_count']}")
905
- if has_unlinked:
906
- lines.append(
907
- f"- **Unlinked** (no vBRIEF): {summary['unlinked_count']}"
908
- )
909
- lines.append(
910
- f"- **vBRIEFs without open issue**: {summary['vbriefs_no_open_issue_count']}"
911
- )
912
- lines.append("")
913
-
914
- # Section A: Linked
915
- lines.append("## (a) Open issues with matching vBRIEF provenance")
916
- lines.append("")
917
- if report["linked"]:
918
- for entry in report["linked"]:
919
- files = ", ".join(f"`{f}`" for f in entry["vbrief_files"])
920
- # Legacy shape carries title/url; inverted shape omits both.
921
- title = entry.get("title", "")
922
- suffix = f" {title}" if title else ""
923
- lines.append(f"- #{entry['issue_number']}{suffix} -- {files}")
924
- else:
925
- lines.append("None.")
926
- lines.append("")
927
-
928
- # Section B: Unlinked (legacy three-section report only).
929
- if has_unlinked:
930
- lines.append("## (b) Open issues with NO matching vBRIEF (unlinked)")
931
- lines.append("")
932
- if report["unlinked"]:
933
- for entry in report["unlinked"]:
934
- lines.append(f"- #{entry['issue_number']} {entry['title']}")
935
- else:
936
- lines.append("None.")
937
- lines.append("")
938
-
939
- # Section C: No open issue
940
- lines.append("## (c) vBRIEFs with NO matching open issue (potentially resolved)")
941
- lines.append("")
942
- if report["no_open_issue"]:
943
- for entry in report["no_open_issue"]:
944
- files = ", ".join(f"`{f}`" for f in entry["vbrief_files"])
945
- lines.append(
946
- f"- #{entry['issue_number']} -- {files} ({entry['note']})"
947
- )
948
- else:
949
- lines.append("None.")
950
- lines.append("")
951
-
952
- return "\n".join(lines)
953
-
954
-
955
- # ---------------------------------------------------------------------------
956
- # Apply-mode helpers (#734 -- --apply-lifecycle-fixes)
957
- # ---------------------------------------------------------------------------
958
-
959
-
960
- def _utc_now_iso() -> str:
961
- """Return the current UTC time as an ISO-8601 string with ``Z`` suffix.
962
-
963
- The shape matches the existing migrator / refinement-skill stamp format
964
- (``2026-04-29T22:48:22Z``). Seconds-precision is sufficient -- the
965
- field is human-auditable, not a high-resolution timestamp.
966
- """
967
- return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
968
-
969
-
970
- def _propagate_item_status(items: list, item_status: str, stamp: str) -> int:
971
- """Flip every item's ``status`` to ``item_status`` and stamp ``completed``.
972
-
973
- Walks ``plan.items[*]`` recursively -- including the nested ``subItems``
974
- and ``items`` arrays that ``extract_references_from_vbrief`` traverses --
975
- so a vBRIEF with sub-item trees lands fully consistent rather than only
976
- flipping the top level. Each touched item gets ``status = item_status``
977
- (``"completed"`` or ``"cancelled"``) and an item-level ISO-8601 UTC
978
- ``completed`` timestamp mirroring PR #921's hand-applied
979
- ``plan.items[*].completed`` pattern. Returns the number of items touched
980
- (#924).
981
- """
982
- touched = 0
983
- for item in items:
984
- if not isinstance(item, dict):
985
- continue
986
- item["status"] = item_status
987
- item["completed"] = stamp
988
- touched += 1
989
- # ``.get(key) or []`` (not ``.get(key, [])``): a present key with an
990
- # explicit JSON ``null`` value returns ``None`` from ``.get(key, [])``
991
- # because the default only applies to ABSENT keys. Passing ``None``
992
- # into the recursion would raise ``TypeError: 'NoneType' object is
993
- # not iterable`` and abort the whole lifecycle-fix batch mid-loop
994
- # (#924 defensive hardening).
995
- touched += _propagate_item_status(
996
- item.get("subItems") or [], item_status, stamp
997
- )
998
- touched += _propagate_item_status(
999
- item.get("items") or [], item_status, stamp
1000
- )
1001
- return touched
1002
-
1003
-
1004
- def _destination_folder(state_reason: str | None) -> str:
1005
- """Map a CLOSED issue's ``stateReason`` to a terminal folder (#1290).
1006
-
1007
- ``NOT_PLANNED`` and ``DUPLICATE`` route to ``cancelled/``; everything
1008
- else (``COMPLETED``, a null reason, or the ``NOT_FOUND`` sentinel)
1009
- routes to ``completed/`` -- the pre-#1290 default behaviour.
1010
- """
1011
- if state_reason in CANCELLED_STATE_REASONS:
1012
- return "cancelled"
1013
- return "completed"
1014
-
1015
-
1016
- def _git_mv(src: Path, dst: Path, *, cwd: Path | None = None) -> bool:
1017
- """Move ``src`` -> ``dst`` using ``git mv`` when possible.
1018
-
1019
- Falls back to ``shutil.move`` when ``git`` is not on PATH or the
1020
- project is not a git repo (e.g. a synthetic test fixture). Returns
1021
- True on success. Raises no exception -- the caller maps a False
1022
- return to a per-file failure for the apply-mode summary.
1023
- """
1024
- if shutil.which("git") is None:
1025
- try:
1026
- shutil.move(str(src), str(dst))
1027
- return True
1028
- except OSError:
1029
- return False
1030
- try:
1031
- result = subprocess.run(
1032
- ["git", "mv", str(src), str(dst)],
1033
- capture_output=True,
1034
- text=True,
1035
- timeout=30,
1036
- cwd=str(cwd) if cwd is not None else None,
1037
- )
1038
- except (FileNotFoundError, subprocess.TimeoutExpired):
1039
- try:
1040
- shutil.move(str(src), str(dst))
1041
- return True
1042
- except OSError:
1043
- return False
1044
- if result.returncode != 0:
1045
- # Fall back to filesystem move (synthetic fixtures / non-git
1046
- # trees). This keeps the apply-mode robust against partial
1047
- # repo layouts while still preferring git semantics when
1048
- # available.
1049
- try:
1050
- shutil.move(str(src), str(dst))
1051
- return True
1052
- except OSError:
1053
- return False
1054
- return True
1055
-
1056
-
1057
- def apply_lifecycle_fixes(
1058
- vbrief_dir: Path,
1059
- report: dict,
1060
- *,
1061
- project_root: Path | None = None,
1062
- ) -> tuple[int, int, list[str]]:
1063
- """Move non-terminal Section (c) entries to a terminal folder.
1064
-
1065
- Iterates ``report['no_open_issue']`` and for each vBRIEF file path
1066
- that is NOT already in a terminal lifecycle folder:
1067
-
1068
- 1. Read the JSON.
1069
- 2. Route by the entry's ``state_reason`` (#1290): CLOSED+NOT_PLANNED
1070
- and CLOSED+DUPLICATE go to ``cancelled/`` (``plan.status =
1071
- "cancelled"``); everything else (COMPLETED / null reason /
1072
- NOT_FOUND) goes to ``completed/`` (``plan.status = "completed"``).
1073
- Entries without a ``state_reason`` key (legacy callers / hand-built
1074
- reports) default to ``completed/`` -- the pre-#1290 behaviour.
1075
- 3. Stamp ``vBRIEFInfo.updated`` with the current UTC ISO timestamp.
1076
- 4. Write the file back (UTF-8, no BOM, trailing newline).
1077
- 5. ``git mv`` (or filesystem-move) the file into the routed folder.
1078
-
1079
- The function is intentionally idempotent: a second call with a
1080
- fresh report (where every entry already lives in ``completed/`` or
1081
- ``cancelled/``) is a no-op. Reverse mismatches (vBRIEFs already in a
1082
- terminal folder whose issue was reopened) are skipped silently here -- they are
1083
- surfaced in the report's Section (a) / (c) split, but auto-reverse
1084
- is intentionally NOT performed (operator decision per #734).
1085
-
1086
- Returns ``(moved, skipped, failures)`` where ``failures`` is a list
1087
- of human-readable failure descriptions (empty on the happy path).
1088
-
1089
- #756: Section (c) entries are deduplicated by relative path BEFORE
1090
- the move loop runs. A single vBRIEF that references multiple closed
1091
- issues appears once per issue in the report; without dedup the
1092
- second-and-later iterations attempt to re-move the same file --
1093
- the first move succeeds, the rest fail with the spurious
1094
- ``vBRIEF file missing`` diagnostic and the function exits with
1095
- ``failures != []`` even though the lifecycle move itself was
1096
- correct. The pre-computed unique set preserves the surfacing order
1097
- of the report (each path is processed in first-seen order) so the
1098
- ``[N/M] vBRIEFs reconciled`` summary keeps stable output across
1099
- runs.
1100
- """
1101
- moved = 0
1102
- skipped = 0
1103
- failures: list[str] = []
1104
- cwd = project_root if project_root is not None else vbrief_dir.parent
1105
-
1106
- # #756: pre-compute the unique candidate set in first-seen order so
1107
- # a vBRIEF that references multiple closed issues lands in its
1108
- # terminal folder exactly once. ``dict`` preserves insertion order
1109
- # while collapsing duplicates; the value records the first-seen
1110
- # ``state_reason`` so #1290 routing is stable across duplicate
1111
- # entries for the same path.
1112
- rel_reasons: dict[str, str | None] = {}
1113
- for entry in report.get("no_open_issue", []):
1114
- reason = entry.get("state_reason")
1115
- for rel_path in entry.get("vbrief_files", []):
1116
- if rel_path not in rel_reasons:
1117
- rel_reasons[rel_path] = reason
1118
-
1119
- for rel_path, state_reason in rel_reasons.items():
1120
- try:
1121
- folder, filename = rel_path.split("/", 1)
1122
- except ValueError:
1123
- failures.append(
1124
- f"unexpected vBRIEF path shape (no folder): {rel_path!r}"
1125
- )
1126
- continue
1127
- if is_terminal_lifecycle_path(rel_path):
1128
- # Already terminal; no-op.
1129
- skipped += 1
1130
- continue
1131
-
1132
- # #1290: route by stateReason. NOT_PLANNED / DUPLICATE ->
1133
- # cancelled/; COMPLETED / null / NOT_FOUND -> completed/.
1134
- dest_folder = _destination_folder(state_reason)
1135
- src = vbrief_dir / folder / filename
1136
- dst = vbrief_dir / dest_folder / filename
1137
- if not src.is_file():
1138
- failures.append(f"vBRIEF file missing: {rel_path}")
1139
- continue
1140
-
1141
- try:
1142
- data = json.loads(src.read_text(encoding="utf-8"))
1143
- except (json.JSONDecodeError, OSError) as exc:
1144
- failures.append(f"failed to parse {rel_path}: {exc}")
1145
- continue
1146
-
1147
- # Greptile P1: check for a destination conflict BEFORE
1148
- # mutating the source file on disk. Previously the
1149
- # write-back happened before ``dst.exists()`` so a
1150
- # collision left the source vBRIEF in an inconsistent
1151
- # half-completed state (status stamped on disk but the file
1152
- # still in its original lifecycle folder). Now the conflict
1153
- # guard fires before any write, so the source file stays
1154
- # byte-identical when the move cannot proceed.
1155
- (vbrief_dir / dest_folder).mkdir(parents=True, exist_ok=True)
1156
- if dst.exists():
1157
- failures.append(
1158
- f"target already exists in {dest_folder}/: {filename}"
1159
- )
1160
- continue
1161
-
1162
- # Stamp status + updated. cancelled/ vBRIEFs get
1163
- # plan.status="cancelled"; completed/ get "completed".
1164
- plan = data.setdefault("plan", {})
1165
- terminal_status = (
1166
- "cancelled" if dest_folder == "cancelled" else "completed"
1167
- )
1168
- plan["status"] = terminal_status
1169
- stamp = _utc_now_iso()
1170
- info = data.setdefault("vBRIEFInfo", {})
1171
- info["updated"] = stamp
1172
- # Mirror the migrator pattern: also stamp ``plan.updated`` so
1173
- # downstream tooling that prefers the plan-level field stays
1174
- # current. Pre-existing files without the key gain it.
1175
- plan["updated"] = stamp
1176
- # #924: propagate the terminal status down to every
1177
- # plan.items[*] (recursively, incl. subItems/items) and stamp an
1178
- # item-level ISO-8601 UTC ``completed`` timestamp. Without this
1179
- # the on-disk record is internally inconsistent (plan.status
1180
- # flipped, items still "proposed"/"pending") and the next
1181
- # reconcile/refinement pass re-flags the file as drifted.
1182
- # ``.get("items") or []`` guards against an explicit ``"items": null``
1183
- # in the on-disk JSON (the ``.get(key, [])`` default only applies to
1184
- # ABSENT keys, so a present null would otherwise reach the recursion
1185
- # as ``None`` and abort the batch).
1186
- _propagate_item_status(plan.get("items") or [], terminal_status, stamp)
1187
-
1188
- # Write back (UTF-8, no BOM, trailing newline; matches the
1189
- # canonical writer style elsewhere in the script).
1190
- try:
1191
- src.write_text(
1192
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
1193
- encoding="utf-8",
1194
- )
1195
- except OSError as exc:
1196
- failures.append(f"failed to write {rel_path}: {exc}")
1197
- continue
1198
-
1199
- if not _git_mv(src, dst, cwd=cwd):
1200
- failures.append(f"failed to move {rel_path} -> {dest_folder}/")
1201
- continue
1202
- moved += 1
1203
-
1204
- return moved, skipped, failures
1205
-
1206
-
1207
- # ---------------------------------------------------------------------------
1208
- # CLI entry point
1209
- # ---------------------------------------------------------------------------
1210
-
1211
-
1212
- def main() -> int:
1213
- import argparse
1214
-
1215
- parser = argparse.ArgumentParser(
1216
- description="Reconcile GitHub issues against vBRIEF references."
1217
- )
1218
- parser.add_argument(
1219
- "--vbrief-dir",
1220
- default="./vbrief",
1221
- help="Path to vbrief/ directory (default: ./vbrief)",
1222
- )
1223
- parser.add_argument(
1224
- "--repo",
1225
- default=None,
1226
- help=(
1227
- "GitHub repo in OWNER/REPO format. Highest precedence; beats "
1228
- "$DEFT_PROJECT_REPO and git-remote detection. Without a flag, "
1229
- "env var, or git remote in the project root the script FAILS "
1230
- "loudly rather than silently falling back to deft's own remote "
1231
- "(#538)."
1232
- ),
1233
- )
1234
- parser.add_argument(
1235
- "--project-root",
1236
- default=None,
1237
- help=(
1238
- "Consumer project root. Used as CWD for git-remote detection "
1239
- "so ``gh`` / ``git`` queries target the consumer repo, not "
1240
- "deftai/directive (#538)."
1241
- ),
1242
- )
1243
- parser.add_argument(
1244
- "--format",
1245
- choices=["json", "markdown"],
1246
- default="markdown",
1247
- help="Output format (default: markdown)",
1248
- )
1249
- parser.add_argument(
1250
- "--apply-lifecycle-fixes",
1251
- action="store_true",
1252
- default=False,
1253
- help=(
1254
- "Apply Section (c) fixes: move non-terminal closed-issue "
1255
- "vBRIEFs to completed/, stamp plan.status=completed and "
1256
- "vBRIEFInfo.updated. Idempotent on re-run. Reverse "
1257
- "mismatches (terminal vBRIEF + reopened issue) are "
1258
- "report-only -- never auto-reverse-moved. (#734)"
1259
- ),
1260
- )
1261
- parser.add_argument(
1262
- "--report-unlinked",
1263
- action="store_true",
1264
- default=False,
1265
- help=(
1266
- "Emit the legacy three-section report including the "
1267
- "``unlinked`` bucket (open issues with no matching vBRIEF). "
1268
- "Requires fetching every open issue in the repo, which "
1269
- "scales by O(repo-open-issue-count). Default invocation "
1270
- "uses the inverted-lookup path (#754) and emits only "
1271
- "sections (a) and (c)."
1272
- ),
1273
- )
1274
- parser.add_argument(
1275
- "--max-open-issues",
1276
- type=int,
1277
- default=DEFAULT_MAX_OPEN_ISSUES,
1278
- metavar="N",
1279
- help=(
1280
- f"Safety cap for the --report-unlinked path (default "
1281
- f"{DEFAULT_MAX_OPEN_ISSUES}). When the paginated open-issue "
1282
- "fetch exceeds N, abort cleanly with exit 1 and a "
1283
- "diagnostic. Raise the cap explicitly when invoking "
1284
- "--report-unlinked on a large repo. (#754)"
1285
- ),
1286
- )
1287
-
1288
- args = parser.parse_args()
1289
- vbrief_dir = Path(args.vbrief_dir).resolve()
1290
-
1291
- if not vbrief_dir.is_dir():
1292
- print(f"Error: vbrief directory not found: {vbrief_dir}", file=sys.stderr)
1293
- return 1
1294
-
1295
- # Resolve repo using the shared precedence: --repo > $DEFT_PROJECT_REPO >
1296
- # git-remote in the (resolved) project root > legacy CWD-scoped
1297
- # ``detect_repo()`` fallback. Never silently fall through to deft's own
1298
- # origin (#538).
1299
- project_root = resolve_project_root(args.project_root)
1300
- repo = resolve_project_repo(args.repo, project_root=project_root)
1301
- if repo is None:
1302
- repo = detect_repo()
1303
- if repo is None:
1304
- print(
1305
- "Error: could not detect repo. "
1306
- "Pass --repo OWNER/NAME, set $DEFT_PROJECT_REPO, or run from "
1307
- "a directory tree whose git remote origin is the consumer "
1308
- "repo (#538).",
1309
- file=sys.stderr,
1310
- )
1311
- # Exit 2 for this usage-style error keeps reconcile:issues
1312
- # consistent with issue_ingest.py and scope_lifecycle.py, so
1313
- # CI scripts/shell conditionals can treat "no repo detected"
1314
- # as a single exit-code bucket (Greptile P2 on #562).
1315
- return 2
1316
-
1317
- # Scan vBRIEFs
1318
- issue_to_vbriefs = scan_vbrief_dir(vbrief_dir)
1319
-
1320
- # #754: branch on ``--report-unlinked``. The default path uses the
1321
- # inverted-lookup helper -- O(vBRIEF-referenced-issue-count) cost,
1322
- # no truncation possible. The opt-in legacy path fetches every open
1323
- # issue and emits the three-section report; capped by
1324
- # ``--max-open-issues`` so a 15k-open-issue repo cannot surprise
1325
- # operators with a 30s+ fetch.
1326
- # #1290 Phase B: resolve each vBRIEF's canonical lifecycle anchor
1327
- # (planRef-first) so apply-mode never drags a cohort member into a
1328
- # closed umbrella's terminal state. Computed only when apply-mode is
1329
- # requested; the state fetch then covers both the reference-based
1330
- # scan (human report) and the canonical anchors (apply candidates),
1331
- # so a planRef issue absent from references[] still gets its state.
1332
- anchors: list[dict] = []
1333
- needed = set(issue_to_vbriefs.keys())
1334
- if args.apply_lifecycle_fixes:
1335
- anchors = scan_lifecycle_anchors(vbrief_dir)
1336
- needed |= {
1337
- a["issue_number"] for a in anchors if a["issue_number"] is not None
1338
- }
1339
-
1340
- issue_state_map: dict[int, IssueState] | None = None
1341
- if args.report_unlinked:
1342
- open_issues = fetch_all_open_issues(repo, cwd=project_root)
1343
- if open_issues is None:
1344
- return 1
1345
- if len(open_issues) > args.max_open_issues:
1346
- print(
1347
- f"Error: {len(open_issues)} open issues exceeds "
1348
- f"--max-open-issues={args.max_open_issues}; raise the "
1349
- "cap or drop --report-unlinked",
1350
- file=sys.stderr,
1351
- )
1352
- return 1
1353
- report = reconcile_with_unlinked(issue_to_vbriefs, open_issues)
1354
- # Apply-mode still needs anchor states even on the legacy path.
1355
- if args.apply_lifecycle_fixes:
1356
- issue_state_map = fetch_issue_states(
1357
- repo, needed, cwd=project_root
1358
- )
1359
- if issue_state_map is None:
1360
- return 1
1361
- else:
1362
- # Inverted lookup: query just the vBRIEF-referenced subset.
1363
- issue_state_map = fetch_issue_states(repo, needed, cwd=project_root)
1364
- if issue_state_map is None:
1365
- return 1
1366
- report = reconcile(issue_to_vbriefs, issue_state_map)
1367
-
1368
- # Output
1369
- if args.format == "json":
1370
- print(format_json(report))
1371
- else:
1372
- print(format_markdown(report))
1373
-
1374
- # #734/#1290: apply mode -- move non-terminal closed-issue vBRIEFs to
1375
- # their terminal folder (completed/ or cancelled/, routed by
1376
- # stateReason). The apply candidate set is built from the canonical
1377
- # anchors (Phase B), NOT the reference-based human report.
1378
- if args.apply_lifecycle_fixes:
1379
- apply_report = build_lifecycle_report(anchors, issue_state_map or {})
1380
- candidates = sum(
1381
- 1
1382
- for entry in apply_report.get("no_open_issue", [])
1383
- for rel in entry.get("vbrief_files", [])
1384
- if not is_terminal_lifecycle_path(rel)
1385
- )
1386
- moved, skipped, failures = apply_lifecycle_fixes(
1387
- vbrief_dir, apply_report, project_root=project_root
1388
- )
1389
- total = moved + skipped + len(failures)
1390
- print(
1391
- f"[{moved}/{candidates}] vBRIEFs reconciled "
1392
- f"(moved={moved}, already-terminal={skipped}, "
1393
- f"failures={len(failures)})",
1394
- file=sys.stderr,
1395
- )
1396
- for f in failures:
1397
- print(f" -- FAIL: {f}", file=sys.stderr)
1398
- if failures:
1399
- return 1
1400
- # Suppress unused-name warning for ``total``; kept for log clarity.
1401
- del total
1402
-
1403
- return 0
1404
-
1405
-
1406
- def detect_repo() -> str | None:
1407
- """Auto-detect OWNER/REPO from git remote origin.
1408
-
1409
- Legacy fallback kept for backwards compatibility with in-process tests
1410
- that monkeypatch this symbol directly; the primary repo-resolution
1411
- path goes through ``_project_context.resolve_project_repo``. Uses the
1412
- same ``.git``-suffix-aware regex as ``_normalise_repo_slug`` so a
1413
- dotted repo name (``acme/my.project``) isn't silently truncated to
1414
- ``acme/my`` when this fallback IS reached (Greptile P2 on #562).
1415
- """
1416
- try:
1417
- result = subprocess.run(
1418
- ["git", "remote", "get-url", "origin"],
1419
- capture_output=True,
1420
- text=True,
1421
- timeout=10,
1422
- )
1423
- except (FileNotFoundError, subprocess.TimeoutExpired):
1424
- return None
1425
-
1426
- if result.returncode != 0:
1427
- return None
1428
-
1429
- url = result.stdout.strip()
1430
- # Mirrors ``_normalise_repo_slug`` -- the legacy fallback used to
1431
- # share its bug (``[^/.]+`` truncates dotted names).
1432
- m = re.search(
1433
- r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?(?:\s|$)",
1434
- url,
1435
- )
1436
- if m:
1437
- return f"{m.group(1)}/{m.group(2)}"
1438
- return None
1439
-
1440
-
1441
- if __name__ == "__main__":
1442
- raise SystemExit(main())