@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,1064 +0,0 @@
1
- #!/usr/bin/env python3
2
- r"""
3
- issue_ingest.py -- Ingest GitHub issues into vBRIEF lifecycle folders.
4
-
5
- Every post-GA issue would otherwise live only on GitHub and reappear in the
6
- ``task reconcile:issues`` unlinked section monotonically -- this script lets a
7
- maintainer (or an agent running the refinement skill) materialise an issue as a
8
- scope vBRIEF with origin provenance so the rest of the framework can reason
9
- about it. Single-issue mode fetches one issue number and writes one scope
10
- vBRIEF; bulk mode scans all open issues (optionally filtered by label) and
11
- ingests anything not already referenced by an existing vBRIEF.
12
-
13
- Usage:
14
- uv run python scripts/issue_ingest.py <N> [--status proposed|pending|active]
15
- uv run python scripts/issue_ingest.py --all [--label LABEL]
16
- [--status STATUS] [--dry-run]
17
- uv run python scripts/issue_ingest.py [--vbrief-dir DIR] [--repo OWNER/REPO] ...
18
-
19
- Exit codes:
20
- 0 -- ingest completed successfully
21
- 1 -- duplicate (single-issue mode; the issue already has a vBRIEF)
22
- 2 -- external error (missing gh, API failure, usage error)
23
-
24
- Story: #454 (task issue:ingest).
25
-
26
- Issue bodies are opaque upstream Markdown text. Never decode them through
27
- Python or JSON string-escape semantics after the GitHub JSON payload has been
28
- parsed; literal substrings such as ``\vbrief``, ``\task``, ``\n``, and
29
- ``\u0041`` must remain literal text in ``plan.narratives.Overview``.
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- import argparse
35
- import json
36
- import re
37
- import subprocess
38
- import sys
39
- from pathlib import Path
40
- from typing import Any
41
-
42
- # Make sibling scripts importable both when run as __main__ and when imported
43
- # by tests that pre-populate sys.path with the ``scripts/`` directory.
44
- sys.path.insert(0, str(Path(__file__).resolve().parent))
45
-
46
- # #1145 / N5: route the ``gh api`` round-trip in :func:`_fetch_single_issue`
47
- # through the source-aware shim so a future GitLab / Gitea / local consumer
48
- # sees ``NotImplementedError`` pointing at #445 / #935 Workstream 6 instead
49
- # of a confusing ``gh: command not found`` deep in the call stack. The shim
50
- # resolves the binary via the #884 ``ghx`` -> ``gh`` preference ladder.
51
- import scm # noqa: E402 -- sibling-first path insertion above is intentional
52
- from _project_context import resolve_project_repo, resolve_project_root # noqa: E402
53
- from _stdio_utf8 import reconfigure_stdio # noqa: E402
54
- from _vbrief_build import EMITTED_VBRIEF_VERSION, TODAY, slugify # noqa: E402
55
- from reconcile_issues import ( # noqa: E402
56
- GITHUB_ISSUE_REF_TYPES,
57
- LIFECYCLE_FOLDERS,
58
- detect_repo,
59
- extract_references_from_vbrief,
60
- fetch_open_issues,
61
- parse_issue_number,
62
- )
63
-
64
- # #883 unified cache surface (optional). When present we prefer the cached
65
- # raw.json payload over a live ``gh api`` round-trip so a Phase 0 walk that
66
- # pre-populated the cache (``task cache:fetch-all``) does not re-spend the
67
- # REST budget per issue. The import is guarded so this module imports cleanly
68
- # in checkouts where ``scripts/cache.py`` is not yet on the branch -- tests
69
- # substitute fakes via ``monkeypatch.setattr(issue_ingest, "cache", ...)``.
70
- try: # pragma: no cover -- exercised once #883 Story 2 lands.
71
- import cache # type: ignore[import-not-found] # noqa: E402
72
- except ImportError: # pragma: no cover
73
- cache = None # type: ignore[assignment]
74
-
75
- reconfigure_stdio()
76
-
77
- # --- Constants --------------------------------------------------------------
78
-
79
- # Allowed target lifecycle folders for ingestion. The rest (``completed/``,
80
- # ``cancelled/``) are terminal states; a freshly ingested issue doesn't belong
81
- # there.
82
- INGEST_STATUSES: tuple[str, ...] = ("proposed", "pending", "active")
83
-
84
- # #1096: provenance-narrative parsers. ``_build_issue_vbrief`` emits
85
- # ``narratives.Origin = "Ingested from <full-URL>"`` when a browser URL
86
- # resolves, or ``"Ingested from issue #N"`` when no URL is available.
87
- # ``vBRIEFInfo.description = "Scope vBRIEF ingested from GitHub issue #N"``
88
- # is the secondary signal. Both shapes yield the same canonical provenance
89
- # issue number for the dedup pass.
90
- _ORIGIN_URL_RE = re.compile(
91
- r"https?://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)"
92
- )
93
- _ORIGIN_BARE_RE = re.compile(r"issue\s*#(\d+)", re.IGNORECASE)
94
-
95
- # Map status keyword -> (folder, plan.status) pair used in the generated
96
- # scope vBRIEF file.
97
- _STATUS_MAP: dict[str, tuple[str, str]] = {
98
- "proposed": ("proposed", "proposed"),
99
- "pending": ("pending", "pending"),
100
- "active": ("active", "running"),
101
- }
102
-
103
- # #1248: body-parsing patterns. The ingester previously emitted stub-only
104
- # vBRIEFs (no ``Overview``, ``plan.items == []``) which forced the
105
- # refinement workflow to re-read the GitHub issue body by hand. The
106
- # patterns below extract acceptance-criteria checklists, numbered AC
107
- # items, and Closes / Refs / Blocked-by cross-references from the issue
108
- # body so downstream consumers (``deft-directive-refinement``,
109
- # ``task triage:queue`` dedup) have substantive content to project from.
110
-
111
- # GitHub-flavoured Markdown task-list line. Captures the marker (space /
112
- # x / X) and the trailing title text. The trailing ``$`` anchors against
113
- # trailing whitespace so a multi-line list item only contributes the
114
- # first line; deeper nesting / continuation lines are explicitly out of
115
- # scope for v1 and noted as a follow-up in the issue body.
116
- _CHECKBOX_RE = re.compile(
117
- r"^\s*[-*+]\s+\[([ xX])\]\s+(.+?)\s*$",
118
- re.MULTILINE,
119
- )
120
-
121
- # Heading whose text contains "Acceptance Criteria" (case-insensitive).
122
- # Used as the entry point for the AC-section fallback when the body
123
- # carries no checkbox-style task list.
124
- _AC_HEADING_RE = re.compile(
125
- r"^(#{1,6})\s+.*\bacceptance\s+criteria\b.*$",
126
- re.IGNORECASE | re.MULTILINE,
127
- )
128
-
129
- # Bullet- or numbered-list item. Used inside the AC section after the
130
- # heading match -- both ``- foo`` / ``* foo`` / ``+ foo`` and
131
- # ``1. foo`` / ``1) foo`` shapes are accepted.
132
- _LIST_ITEM_RE = re.compile(
133
- r"^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$",
134
- re.MULTILINE,
135
- )
136
-
137
- # Closing / referencing / blocking keyword -> canonical ``x-vbrief/*``
138
- # reference type. Ordering is significant: ``blocked by`` is matched
139
- # before ``blocks`` would be (the latter is intentionally absent because
140
- # ``Blocks #N`` on the source issue has the inverse semantic of the
141
- # ingested issue being blocked). Patterns are applied against a body
142
- # stripped of fenced and inline code spans so Markdown examples don't
143
- # produce spurious cross-refs.
144
- _CROSS_REF_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
145
- (
146
- "x-vbrief/closes",
147
- re.compile(
148
- r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b",
149
- re.IGNORECASE,
150
- ),
151
- ),
152
- (
153
- "x-vbrief/blocks",
154
- re.compile(
155
- r"\bblocked[\s\-]+by\s+#(\d+)\b",
156
- re.IGNORECASE,
157
- ),
158
- ),
159
- (
160
- "x-vbrief/refs",
161
- re.compile(
162
- r"\b(?:refs?|references?|see\s+also|related(?:\s+to)?)\s+#(\d+)\b",
163
- re.IGNORECASE,
164
- ),
165
- ),
166
- )
167
-
168
- # Fenced code block (triple-backtick OR tilde-fence) and inline code
169
- # span (single backtick, no embedded backtick / newline). Stripped
170
- # before cross-ref / plan-item extraction so a body that quotes
171
- # ``Closes #N`` as an illustration does not produce a real cross-ref.
172
- # The capturing group + ``\1`` backreference enforces matching
173
- # delimiters (a ``~~~`` fence cannot be closed by ``\`\`\``) per the
174
- # GitHub Flavoured Markdown spec.
175
- _CODE_FENCE_RE = re.compile(r"(```|~~~).*?\1", re.DOTALL)
176
- _INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
177
-
178
- _CONTROL_CHAR_LABELS: dict[str, str] = {
179
- "\b": "U+0008 backspace",
180
- "\t": "U+0009 tab",
181
- "\v": "U+000B vertical tab",
182
- "\f": "U+000C form feed",
183
- }
184
-
185
-
186
- # --- Helpers ----------------------------------------------------------------
187
-
188
-
189
- def _strip_code_blocks(body: str) -> str:
190
- """Return ``body`` with Markdown code spans elided (#1248).
191
-
192
- Fenced code blocks (triple-backtick) and inline single-backtick code
193
- spans are replaced with the empty string before cross-ref / plan-item
194
- extraction. This prevents an issue body that *quotes* ``Closes #N``
195
- as a syntax example (the #1248 body is itself an example -- it
196
- embeds a JSON block illustrating the stub-only shape) from producing
197
- spurious cross-refs or plan-items.
198
- """
199
- if not body:
200
- return ""
201
- return _INLINE_CODE_RE.sub("", _CODE_FENCE_RE.sub("", body))
202
-
203
-
204
- def _has_non_indentation_prefix(text: str, index: int) -> bool:
205
- """Return True when a tab at ``index`` follows non-whitespace on its line."""
206
- line_start = text.rfind("\n", 0, index) + 1
207
- return any(ch not in " \t" for ch in text[line_start:index])
208
-
209
-
210
- def _body_control_character_labels(body: str) -> list[str]:
211
- """Return visible labels for unexpected control characters in issue body text."""
212
- labels: list[str] = []
213
- seen: set[str] = set()
214
- for index, char in enumerate(body):
215
- if char == "\t" and not _has_non_indentation_prefix(body, index):
216
- continue
217
- label = _CONTROL_CHAR_LABELS.get(char)
218
- if label is None and ord(char) < 32 and char not in {"\n", "\r"}:
219
- label = f"U+{ord(char):04X} control character"
220
- if label and label not in seen:
221
- seen.add(label)
222
- labels.append(label)
223
- return labels
224
-
225
-
226
- def _warn_body_control_characters(number: int, body: str) -> None:
227
- """Surface decoded upstream control characters before writing a vBRIEF."""
228
- labels = _body_control_character_labels(body)
229
- if not labels:
230
- return
231
- print(
232
- f"Warning: issue #{number} body contains unexpected control characters "
233
- f"({', '.join(labels)}); preserving Overview verbatim, but "
234
- "verify_encoding will flag the generated vBRIEF narrative.",
235
- file=sys.stderr,
236
- )
237
-
238
-
239
- def _extract_plan_items(body: str) -> list[dict]:
240
- """Extract ``plan.items[]`` entries from a GitHub issue body (#1248).
241
-
242
- Detection ladder:
243
-
244
- 1. Markdown task-list checkboxes (``- [ ] foo`` / ``- [x] bar``) --
245
- the GitHub-native shape that ``deft-directive-refinement`` and
246
- ``task triage:queue`` both project from. Unchecked boxes map to
247
- ``status = "proposed"``; checked boxes map to
248
- ``status = "completed"`` so an issue that ships partial progress
249
- is reflected honestly.
250
- 2. Bullet- / numbered-list items underneath an ``Acceptance
251
- Criteria`` heading -- the second-most-common shape across the
252
- 2026-05-20 audit cohort. Stops at the next heading at the same
253
- or higher level.
254
- 3. Graceful degradation: when neither shape is present, return an
255
- empty list. The vBRIEF still carries ``narratives.Overview`` so
256
- refinement can refine *something*; ``plan.items`` is only ever
257
- populated when there is structured source material to project
258
- from.
259
-
260
- Every emitted item carries the schema-required ``title`` + ``status``
261
- keys (``minLength: 1`` on ``title``; ``status`` from the canonical
262
- ``Status`` enum). Duplicate titles are de-duped while preserving
263
- document order.
264
- """
265
- if not body:
266
- return []
267
- text = _strip_code_blocks(body)
268
-
269
- items: list[dict] = []
270
- seen: set[str] = set()
271
- for match in _CHECKBOX_RE.finditer(text):
272
- marker = match.group(1)
273
- title_text = match.group(2).strip()
274
- if not title_text or title_text in seen:
275
- continue
276
- seen.add(title_text)
277
- status = "completed" if marker.lower() == "x" else "proposed"
278
- items.append({"title": title_text, "status": status})
279
- if items:
280
- return items
281
-
282
- # Fallback: numbered / bulleted list under an Acceptance Criteria heading.
283
- return _extract_ac_section_items(text)
284
-
285
-
286
- def _extract_ac_section_items(text: str) -> list[dict]:
287
- """Extract list items from an Acceptance Criteria section (#1248 fallback).
288
-
289
- Walks for an ``Acceptance Criteria`` heading (any level 1-6,
290
- case-insensitive). When found, slices the body to the section --
291
- bounded by the next heading at the same-or-higher level -- and
292
- returns each bullet / numbered list item as a PlanItem dict with
293
- ``status = "proposed"``.
294
- """
295
- heading_match = _AC_HEADING_RE.search(text)
296
- if not heading_match:
297
- return []
298
- heading_level = len(heading_match.group(1))
299
- section_start = heading_match.end()
300
- next_heading_re = re.compile(
301
- rf"^#{{1,{heading_level}}}\s+\S",
302
- re.MULTILINE,
303
- )
304
- after = text[section_start:]
305
- next_match = next_heading_re.search(after)
306
- section_text = after[: next_match.start()] if next_match else after
307
-
308
- items: list[dict] = []
309
- seen: set[str] = set()
310
- for li in _LIST_ITEM_RE.finditer(section_text):
311
- title_text = li.group(1).strip()
312
- # Defensive: strip a leftover ``[ ]`` / ``[x]`` checkbox prefix
313
- # if a maintainer mixed checkbox + numbered shapes inside the
314
- # AC section. Preserve the checked state so a completed item
315
- # in a numbered+checkbox mixed AC list lands as ``completed``
316
- # rather than being silently demoted to ``proposed`` (downstream
317
- # consumers ``deft-directive-refinement`` / ``task triage:queue``
318
- # treat ``status`` as signal for remaining work).
319
- status = "proposed"
320
- cb = re.match(r"\[([ xX])\]\s+(.+)", title_text)
321
- if cb:
322
- title_text = cb.group(2).strip()
323
- if cb.group(1).lower() == "x":
324
- status = "completed"
325
- if not title_text or title_text in seen:
326
- continue
327
- seen.add(title_text)
328
- items.append({"title": title_text, "status": status})
329
- return items
330
-
331
-
332
- def _extract_cross_refs(
333
- body: str,
334
- repo_url: str,
335
- *,
336
- exclude: set[int] | None = None,
337
- ) -> list[dict]:
338
- """Extract Closes / Refs / Blocked-by cross-refs from issue body (#1248).
339
-
340
- Returns a list of canonical ``VBriefReference`` dicts (``{uri, type,
341
- title}``) ready to append to ``plan.references[]``. Reference types:
342
-
343
- - ``x-vbrief/closes`` for ``Closes / Fixes / Resolves #N`` (inflected
344
- forms ``closed`` / ``fixed`` / ``resolved`` accepted).
345
- - ``x-vbrief/blocks`` for ``Blocked by #N`` -- the dependency
346
- direction the issue body expresses (this scope is blocked by #N).
347
- - ``x-vbrief/refs`` for ``Refs / References / See also / Related #N``.
348
-
349
- Skips matches that fall inside fenced or inline code spans (the
350
- body is passed through :func:`_strip_code_blocks` first) and any
351
- issue number in ``exclude`` -- callers pass the provenance issue
352
- number itself so a self-reference (e.g. ``Closes #1248`` in #1248's
353
- own body) does not produce a duplicate reference to the canonical
354
- ``x-vbrief/github-issue`` origin.
355
-
356
- Returns an empty list when ``repo_url`` is empty -- the canonical
357
- ``VBriefReference`` shape requires ``uri``, and synthesising a
358
- URL without a repo handle would be dishonest.
359
- """
360
- if not body or not repo_url:
361
- return []
362
- text = _strip_code_blocks(body)
363
- refs: list[dict] = []
364
- seen: set[tuple[str, int]] = set()
365
- excluded = exclude or set()
366
- for ref_type, pattern in _CROSS_REF_PATTERNS:
367
- for match in pattern.finditer(text):
368
- number = int(match.group(1))
369
- if number in excluded:
370
- continue
371
- key = (ref_type, number)
372
- if key in seen:
373
- continue
374
- seen.add(key)
375
- refs.append(
376
- {
377
- "uri": f"{repo_url}/issues/{number}",
378
- "type": ref_type,
379
- "title": f"Issue #{number}",
380
- }
381
- )
382
- return refs
383
-
384
-
385
- def _provenance_issue_number(data: dict) -> int | None:
386
- """Extract the provenance issue number from a vBRIEF data dict (#1096).
387
-
388
- A vBRIEF is the *provenance owner* of issue ``#N`` when its
389
- ``plan.narratives.Origin`` (or, as a secondary signal,
390
- ``vBRIEFInfo.description``) states it was ingested from ``#N``. Both
391
- canonical Origin shapes emitted by :func:`_build_issue_vbrief` are
392
- accepted:
393
-
394
- - ``Ingested from https://github.com/<owner>/<repo>/issues/<N>``
395
- - ``Ingested from issue #<N>`` (no-URL fallback)
396
-
397
- Returns the provenance issue number or ``None`` when the vBRIEF carries
398
- no recognisable ``Ingested from ...`` signal (e.g. a hand-authored
399
- kaizen brief that merely references GitHub issues, or a legacy v0.5
400
- fixture predating the Origin convention -- the caller's fallback
401
- heuristic in :func:`_scan_provenance_refs` handles back-compat).
402
- """
403
- if not isinstance(data, dict):
404
- return None
405
- plan = data.get("plan", {})
406
- narratives = plan.get("narratives", {}) if isinstance(plan, dict) else {}
407
- origin = (
408
- narratives.get("Origin", "") if isinstance(narratives, dict) else ""
409
- )
410
- info = data.get("vBRIEFInfo", {})
411
- description = info.get("description", "") if isinstance(info, dict) else ""
412
-
413
- for text in (origin, description):
414
- if not isinstance(text, str) or not text:
415
- continue
416
- m = _ORIGIN_URL_RE.search(text)
417
- if m:
418
- return int(m.group(1))
419
- m = _ORIGIN_BARE_RE.search(text)
420
- if m:
421
- return int(m.group(1))
422
- return None
423
-
424
-
425
- def _scan_provenance_refs(vbrief_dir: Path) -> dict[int, list[str]]:
426
- """Scan vBRIEF lifecycle folders and return a provenance-only dedup map (#1096).
427
-
428
- Differentiates *provenance* references (the vBRIEF was actually
429
- ingested from issue ``#N`` -- ``plan.narratives.Origin`` confirms it
430
- AND a canonical ``x-vbrief/github-issue`` reference points at ``#N``)
431
- from *informational* references (companion / sibling / related-plan
432
- mentions, even when typed ``x-vbrief/github-issue``). Only the
433
- provenance owner of an issue is returned, so ``task issue:ingest --
434
- <N>`` no longer false-positives on informational references that
435
- merely mention ``#N`` (closes #1096).
436
-
437
- Per-vBRIEF resolution rule:
438
-
439
- 1. If ``plan.narratives.Origin`` (or ``vBRIEFInfo.description``)
440
- identifies a provenance issue number ``P`` AND any
441
- ``x-vbrief/github-issue`` reference points at ``P`` -> that vBRIEF
442
- is the provenance owner of ``P`` (only). Other
443
- ``x-vbrief/github-issue`` references on the same vBRIEF are
444
- treated as informational and contribute nothing to the dedup map.
445
- 2. If no ``Origin`` provenance signal is present (legacy v0.5
446
- fixtures, hand-authored stubs) -> fall back to the FIRST
447
- ``x-vbrief/github-issue`` reference as the implied provenance.
448
- This preserves dedup for unmigrated trees per the #1096 vBRIEF's
449
- out-of-scope clause ("the fix should make new ingest correct
450
- without requiring a data-migration sweep first").
451
-
452
- Returns:
453
- Mapping of issue_number -> list of vBRIEF file paths (relative to
454
- ``vbrief_dir``) where each listed vBRIEF is the *provenance* owner
455
- of that issue. The list shape matches
456
- :func:`reconcile_issues.scan_vbrief_dir` so callers can swap the
457
- two functions transparently.
458
- """
459
- issue_to_vbriefs: dict[int, list[str]] = {}
460
-
461
- for folder in LIFECYCLE_FOLDERS:
462
- folder_path = vbrief_dir / folder
463
- if not folder_path.is_dir():
464
- continue
465
- for vbrief_file in sorted(folder_path.glob("*.vbrief.json")):
466
- try:
467
- data = json.loads(vbrief_file.read_text(encoding="utf-8"))
468
- except (json.JSONDecodeError, OSError):
469
- continue
470
-
471
- refs = extract_references_from_vbrief(data)
472
- github_refs: list[tuple[dict, int]] = []
473
- for ref in refs:
474
- if ref.get("type") not in GITHUB_ISSUE_REF_TYPES:
475
- continue
476
- num = parse_issue_number(ref)
477
- if num is not None:
478
- github_refs.append((ref, num))
479
-
480
- if not github_refs:
481
- continue
482
-
483
- provenance_num = _provenance_issue_number(data)
484
- if provenance_num is not None:
485
- # Origin/description identifies ``provenance_num`` -- only
486
- # count the matching github-issue ref. Companion refs to
487
- # other issues on the same vBRIEF are informational.
488
- if not any(num == provenance_num for _, num in github_refs):
489
- # Origin narrative claims a number not borne out by any
490
- # github-issue reference. Honest behaviour is to skip
491
- # this vBRIEF -- treating the Origin claim alone as
492
- # provenance would re-introduce the false-positive
493
- # surface the legacy-ref fallback below is bounded
494
- # against.
495
- continue
496
- owner_num = provenance_num
497
- else:
498
- # Legacy fallback: first github-issue ref is provenance.
499
- owner_num = github_refs[0][1]
500
-
501
- rel_path = f"{folder}/{vbrief_file.name}"
502
- issue_to_vbriefs.setdefault(owner_num, []).append(rel_path)
503
-
504
- return issue_to_vbriefs
505
-
506
-
507
- def _build_issue_vbrief(
508
- issue: dict, status: str, repo_url: str
509
- ) -> tuple[dict, str]:
510
- """Build a scope vBRIEF dict (and the target lifecycle folder) from a GitHub issue dict.
511
-
512
- ``issue`` is the JSON payload returned by ``gh api repos/.../issues/N`` or
513
- one element of the ``gh issue list --json number,title,labels,url,body``
514
- array.
515
-
516
- Emits canonical vBRIEF v0.6 output (#639 + #988):
517
- - ``vBRIEFInfo.version = EMITTED_VBRIEF_VERSION`` (``"0.6"``) -- the
518
- canonical schema pin (const ``"0.6"`` in
519
- ``vbrief/schemas/vbrief-core.schema.json``).
520
- - ``plan.narratives.Overview`` carries the GitHub issue body verbatim
521
- when present (#988). This is the contract documented in
522
- ``skills/deft-directive-swarm/SKILL.md`` Phase 0 Step 0B; the prior
523
- implementation only emitted ``Description`` (= title) and dropped
524
- the body, producing stub vBRIEFs that failed every downstream
525
- "acceptance criteria present" check. ``narratives.Labels`` is kept
526
- for backward compatibility but ``plan.tags`` is now the structured
527
- surface for downstream filtering.
528
- - ``plan.tags`` is a list of label-name strings when the issue carries
529
- labels (#988). The Plan schema's ``tags`` array (line 162 of
530
- ``vbrief/schemas/vbrief-core.schema.json``) accepts arbitrary
531
- strings; this lets consumers filter without parsing the freeform
532
- ``narratives.Labels`` text.
533
- - ``plan.references`` uses the canonical
534
- ``VBriefReference`` shape ``{uri, type: "x-vbrief/github-issue",
535
- title: "Issue #{N}: {title}"}`` documented in
536
- ``conventions/references.md`` (matches ``scripts/_vbrief_build.py::
537
- create_scope_vbrief``). The legacy bare
538
- ``{type: "github-issue", id: "#N", url}`` shape is NEVER emitted.
539
- - When no browser URL can be resolved (neither the issue payload's
540
- ``url`` nor a non-empty ``repo_url``) the reference is omitted --
541
- ``VBriefReference`` requires ``uri``, so we cannot honestly emit
542
- one. The caller still has the issue number in ``plan.narratives["Origin"]``.
543
- """
544
- number = int(issue["number"])
545
- title = str(issue.get("title", f"Issue #{number}")) or f"Issue #{number}"
546
- url = str(issue.get("url", "")) or (
547
- f"{repo_url}/issues/{number}" if repo_url else ""
548
- )
549
- body = issue.get("body")
550
- body_str = str(body) if isinstance(body, str) and body else ""
551
- labels = issue.get("labels", []) or []
552
- label_names = [
553
- (lbl.get("name") if isinstance(lbl, dict) else str(lbl))
554
- for lbl in labels
555
- if (isinstance(lbl, dict) and lbl.get("name")) or isinstance(lbl, str)
556
- ]
557
- folder, plan_status = _STATUS_MAP[status]
558
-
559
- narratives: dict[str, str] = {
560
- "Description": title,
561
- "Origin": f"Ingested from {url}" if url else f"Ingested from issue #{number}",
562
- }
563
- if body_str:
564
- # #988: carry the issue body verbatim to ``narratives.Overview`` so
565
- # the swarm Phase 0 "acceptance criteria present" check has source
566
- # text to project from. #1248 widens this surface by ALSO emitting
567
- # structured ``plan.items[]`` + ``plan.references[]`` cross-refs
568
- # derived from the body, so refinement / triage:queue have more
569
- # than just an opaque blob to work with.
570
- _warn_body_control_characters(number, body_str)
571
- narratives["Overview"] = body_str
572
- if label_names:
573
- narratives["Labels"] = ", ".join(label_names)
574
-
575
- # #1248: derive ``plan.items[]`` from the issue body's task-list /
576
- # acceptance-criteria checklist (graceful degradation to ``[]`` when
577
- # neither shape is present).
578
- plan_items = _extract_plan_items(body_str) if body_str else []
579
-
580
- plan: dict = {
581
- "title": title,
582
- "status": plan_status,
583
- "narratives": narratives,
584
- "items": plan_items,
585
- }
586
- if label_names:
587
- # #988: structured-surface mirror of ``narratives.Labels`` so
588
- # consumers can filter by tag without parsing the freeform string.
589
- plan["tags"] = list(label_names)
590
-
591
- # #639 + #1248: canonical v0.6 VBriefReference shape, with the body-
592
- # derived Closes / Refs / Blocked-by cross-refs appended after the
593
- # canonical ``x-vbrief/github-issue`` origin. Only emit when we have
594
- # a resolvable URL -- the schema requires ``uri`` and we must not
595
- # forge one. Matches ``scripts/_vbrief_build.py::create_scope_vbrief``
596
- # and ``conventions/references.md``.
597
- if url:
598
- references: list[dict] = [
599
- {
600
- "uri": url,
601
- "type": "x-vbrief/github-issue",
602
- "title": f"Issue #{number}: {title}",
603
- }
604
- ]
605
- if body_str and repo_url:
606
- # Use ``repo_url`` (not ``url``) so cross-refs target sibling
607
- # issues under the same repo even when ``url`` already
608
- # resolves a specific issue; exclude ``number`` so a
609
- # self-referencing ``Closes #N`` in the body does not
610
- # duplicate the canonical origin reference above.
611
- references.extend(
612
- _extract_cross_refs(
613
- body_str, repo_url, exclude={number}
614
- )
615
- )
616
- plan["references"] = references
617
-
618
- return {
619
- "vBRIEFInfo": {
620
- "version": EMITTED_VBRIEF_VERSION,
621
- "description": f"Scope vBRIEF ingested from GitHub issue #{number}",
622
- },
623
- "plan": plan,
624
- }, folder
625
-
626
-
627
- def _target_filename(number: int, title: str) -> str:
628
- """Build the ``YYYY-MM-DD-<N>-<slug>.vbrief.json`` filename for an issue."""
629
- slug = slugify(title) or f"issue-{number}"
630
- return f"{TODAY}-{number}-{slug}.vbrief.json"
631
-
632
-
633
- def _fetch_from_cache(
634
- repo: str,
635
- number: int,
636
- *,
637
- cache_root: Path | None = None,
638
- ) -> dict | None:
639
- """Read the unified cache (#883) for ``(github-issue, repo/number)`` if fresh.
640
-
641
- Returns the parsed ``raw.json`` payload when present and not stale,
642
- ``None`` otherwise (cache miss, stale entry, parse failure, or the
643
- cache module is not importable). The caller falls back to a live
644
- ``gh api`` round-trip via :func:`_fetch_single_issue` on ``None``.
645
-
646
- Cache freshness is delegated to :func:`scripts.cache.cache_get` with
647
- ``allow_stale=False`` -- this matches the #883 contract that callers
648
- opt in to stale entries explicitly. The unified cache TTL for
649
- ``github-issue`` is 7 days (see ``scripts/cache.py::SOURCE_TTL_SECONDS``).
650
- """
651
- if cache is None:
652
- return None
653
- key = f"{repo}/{int(number)}"
654
- try:
655
- result = cache.cache_get(
656
- "github-issue", key, cache_root=cache_root, allow_stale=False
657
- )
658
- except Exception: # noqa: BLE001 -- any cache error -> live fetch fallback
659
- return None
660
- raw_path = Path(result.entry_dir) / "raw.json"
661
- if not raw_path.exists():
662
- return None
663
- try:
664
- issue: Any = json.loads(raw_path.read_text(encoding="utf-8"))
665
- except (OSError, json.JSONDecodeError):
666
- return None
667
- if not isinstance(issue, dict):
668
- return None
669
- # Mirror the normalisation _fetch_single_issue applies to live ``gh api``
670
- # output: prefer ``html_url`` (browser URL) over ``url`` (REST API URL)
671
- # when both are present. The cache populated by ``task cache:fetch-all``
672
- # uses ``gh issue list --json ...,url`` which already emits the browser
673
- # URL, so this branch is a no-op for cached payloads -- but keep it
674
- # defensive so a future cache populator using ``gh api`` directly still
675
- # produces honest output here.
676
- if issue.get("html_url"):
677
- issue["url"] = issue["html_url"]
678
- return issue
679
-
680
-
681
- def _fetch_issue(
682
- repo: str,
683
- number: int,
684
- *,
685
- cwd: Path | None = None,
686
- cache_root: Path | None = None,
687
- ) -> dict | None:
688
- """Fetch a single issue, preferring the unified cache over live ``gh api``.
689
-
690
- #988: when ``.deft-cache/github-issue/<owner>/<repo>/<N>/raw.json`` is
691
- fresh, return the cached payload directly so a Phase 0 walk that
692
- pre-populated the cache via ``task cache:fetch-all`` does not re-spend
693
- the REST budget per issue. Falls back to live ``gh api`` on cache miss
694
- or stale entries (per #883 cache freshness rules).
695
- """
696
- cached = _fetch_from_cache(repo, number, cache_root=cache_root)
697
- if cached is not None:
698
- return cached
699
- return _fetch_single_issue(repo, number, cwd=cwd)
700
-
701
-
702
- def _fetch_single_issue(
703
- repo: str,
704
- number: int,
705
- *,
706
- cwd: Path | None = None,
707
- ) -> dict | None:
708
- """Fetch a single issue via ``gh api repos/{repo}/issues/{number}``.
709
-
710
- Routes through :func:`scripts.scm.call` (#1145 / N5) so a future
711
- non-GitHub consumer raises a loud ``NotImplementedError`` pointing at
712
- #445 / #935 Workstream 6 rather than failing deep in the call stack
713
- with ``gh: command not found``. The shim resolves the binary via the
714
- #884 ``ghx`` -> ``gh`` preference ladder so cached responses are
715
- transparently picked up when ``ghx`` is installed.
716
-
717
- Returns the parsed issue dict on success, ``None`` on error (with the
718
- reason printed to stderr).
719
- """
720
- try:
721
- result = scm.call(
722
- "github-issue",
723
- "api",
724
- [f"repos/{repo}/issues/{number}"],
725
- timeout=30,
726
- cwd=str(cwd) if cwd is not None else None,
727
- )
728
- except FileNotFoundError:
729
- print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
730
- return None
731
- except scm.ScmStubError as exc:
732
- print(f"Error: gh CLI resolution failed: {exc}", file=sys.stderr)
733
- return None
734
- except subprocess.TimeoutExpired:
735
- print("Error: gh CLI timed out.", file=sys.stderr)
736
- return None
737
-
738
- if result.returncode != 0:
739
- print(
740
- f"Error: gh CLI failed fetching #{number}: {result.stderr.strip()}",
741
- file=sys.stderr,
742
- )
743
- return None
744
- try:
745
- issue = json.loads(result.stdout)
746
- except json.JSONDecodeError:
747
- print(
748
- f"Error: failed to parse gh CLI output for #{number}.",
749
- file=sys.stderr,
750
- )
751
- return None
752
- # #639 follow-up (Greptile P1): ``gh api repos/{repo}/issues/{N}``
753
- # ALWAYS returns both ``url`` (REST API URL, ``https://api.github.com/repos/...``)
754
- # and ``html_url`` (browser URL, ``https://github.com/{owner}/{repo}/issues/{N}``).
755
- # The previous ``"url" not in issue`` guard was therefore always False for
756
- # real gh api output, so ``issue["url"]`` leaked through as the REST API
757
- # URL and ended up in the canonical ``uri`` field -- contradicting the
758
- # ``conventions/references.md`` spec which requires the browser URL.
759
- # ``fetch_open_issues`` (``gh issue list --json ...,url``) already returns
760
- # ``url`` = browser URL, so unconditionally preferring ``html_url`` when
761
- # present aligns the single-issue and bulk paths.
762
- if "html_url" in issue and issue.get("html_url"):
763
- issue["url"] = issue["html_url"]
764
- return issue
765
-
766
-
767
- # --- Core actions -----------------------------------------------------------
768
-
769
-
770
- def ingest_one(
771
- issue: dict,
772
- *,
773
- vbrief_dir: Path,
774
- status: str,
775
- repo_url: str,
776
- dry_run: bool = False,
777
- existing_refs: dict[int, list[str]] | None = None,
778
- ) -> tuple[str, Path | None, str]:
779
- """Ingest a single issue dict.
780
-
781
- Returns ``(result, path, message)`` where ``result`` is one of ``"created"``,
782
- ``"dryrun"``, or ``"duplicate"``. ``path`` is the written (or would-be) file
783
- path; for ``duplicate`` it points at the pre-existing vBRIEF that already
784
- references this issue.
785
- """
786
- number = int(issue["number"])
787
- # #1096: provenance-aware dedup. Only count vBRIEFs that were actually
788
- # ingested from issue #N (Origin-narrative-confirmed) -- companion /
789
- # related-plan / sibling-mention references that merely cite #N do NOT
790
- # block ingest.
791
- refs = (
792
- existing_refs
793
- if existing_refs is not None
794
- else _scan_provenance_refs(vbrief_dir)
795
- )
796
- if number in refs:
797
- existing = refs[number][0]
798
- return "duplicate", vbrief_dir / existing, f"#{number} already ingested at {existing}"
799
-
800
- vbrief, folder = _build_issue_vbrief(issue, status, repo_url)
801
- filename = _target_filename(number, str(issue.get("title", "")))
802
- target = vbrief_dir / folder / filename
803
-
804
- if dry_run:
805
- return "dryrun", target, f"DRY-RUN would write {folder}/{filename}"
806
-
807
- target.parent.mkdir(parents=True, exist_ok=True)
808
- target.write_text(
809
- json.dumps(vbrief, indent=2, ensure_ascii=False) + "\n",
810
- encoding="utf-8",
811
- )
812
- return "created", target, f"CREATED {folder}/{filename}"
813
-
814
-
815
- def ingest_bulk(
816
- issues: list[dict],
817
- *,
818
- vbrief_dir: Path,
819
- status: str,
820
- repo_url: str,
821
- label: str | None = None,
822
- dry_run: bool = False,
823
- ) -> dict:
824
- """Ingest a list of issues.
825
-
826
- Filters by ``label`` first (if provided), then delegates to
827
- ``ingest_one`` for each remaining issue. Returns a summary dict:
828
- ``{"created": [...], "duplicate": [...], "dryrun": [...], "total": N}``.
829
- """
830
- if label:
831
- filtered = []
832
- for issue in issues:
833
- for lbl in issue.get("labels", []) or []:
834
- name = lbl.get("name") if isinstance(lbl, dict) else str(lbl)
835
- if name == label:
836
- filtered.append(issue)
837
- break
838
- issues = filtered
839
-
840
- # #1096: provenance-aware dedup. See :func:`_scan_provenance_refs`.
841
- refs = _scan_provenance_refs(vbrief_dir)
842
-
843
- # Values are list[str] for the three bucket keys and int for "total",
844
- # hence the union annotation.
845
- summary: dict[str, list[str] | int] = {"created": [], "duplicate": [], "dryrun": []}
846
- for issue in issues:
847
- result, path, _msg = ingest_one(
848
- issue,
849
- vbrief_dir=vbrief_dir,
850
- status=status,
851
- repo_url=repo_url,
852
- dry_run=dry_run,
853
- existing_refs=refs,
854
- )
855
- summary[result].append(str(path.relative_to(vbrief_dir)) if path else "")
856
- # After a real write the refs map would now contain this number;
857
- # update in place so duplicates inside the same batch are detected.
858
- if result == "created":
859
- refs.setdefault(int(issue["number"]), []).append(
860
- str(path.relative_to(vbrief_dir))
861
- )
862
-
863
- summary["total"] = len(issues)
864
- return summary
865
-
866
-
867
- # --- CLI --------------------------------------------------------------------
868
-
869
-
870
- def _resolve_repo_url(repo: str) -> str:
871
- """Produce a browser URL from an OWNER/REPO pair (or empty if none)."""
872
- if not repo:
873
- return ""
874
- if repo.startswith(("http://", "https://")):
875
- return repo.rstrip("/")
876
- if re.match(r"^[^/]+/[^/]+$", repo):
877
- return f"https://github.com/{repo}"
878
- return ""
879
-
880
-
881
- def build_parser() -> argparse.ArgumentParser:
882
- parser = argparse.ArgumentParser(
883
- description="Ingest GitHub issues as scope vBRIEFs in vbrief/ lifecycle folders.",
884
- )
885
- parser.add_argument(
886
- "number",
887
- nargs="?",
888
- type=int,
889
- help="GitHub issue number to ingest (single-issue mode)",
890
- )
891
- parser.add_argument(
892
- "--all",
893
- action="store_true",
894
- help="Bulk mode -- ingest all open issues (optionally filtered by --label)",
895
- )
896
- parser.add_argument(
897
- "--label",
898
- default=None,
899
- help="Only ingest issues carrying this label (bulk mode)",
900
- )
901
- parser.add_argument(
902
- "--status",
903
- default="proposed",
904
- choices=INGEST_STATUSES,
905
- help="Target lifecycle folder / plan.status (default: proposed)",
906
- )
907
- parser.add_argument(
908
- "--dry-run",
909
- action="store_true",
910
- help="Print what would be written without creating files",
911
- )
912
- parser.add_argument(
913
- "--vbrief-dir",
914
- default="./vbrief",
915
- help="Path to vbrief/ directory (default: ./vbrief)",
916
- )
917
- parser.add_argument(
918
- "--repo",
919
- default=None,
920
- help=(
921
- "GitHub repo in OWNER/REPO format. Highest precedence; beats "
922
- "$DEFT_PROJECT_REPO and git-remote detection. Without a flag, "
923
- "env var, or git remote in the project root the script FAILS "
924
- "loudly rather than silently falling back to deft's own remote "
925
- "(#538)."
926
- ),
927
- )
928
- parser.add_argument(
929
- "--project-root",
930
- default=None,
931
- help=(
932
- "Consumer project root. Used as CWD for git-remote detection "
933
- "so ``gh`` / ``git`` queries target the consumer repo, not "
934
- "deftai/directive (#538)."
935
- ),
936
- )
937
- return parser
938
-
939
-
940
- def main(argv: list[str] | None = None) -> int:
941
- parser = build_parser()
942
- args = parser.parse_args(argv)
943
-
944
- if args.number is None and not args.all:
945
- parser.error("Provide an issue number or --all")
946
-
947
- if args.number is not None and args.all:
948
- parser.error("Use either a single issue number OR --all, not both")
949
-
950
- vbrief_dir = Path(args.vbrief_dir).resolve()
951
- if not vbrief_dir.exists():
952
- vbrief_dir.mkdir(parents=True, exist_ok=True)
953
-
954
- project_root = resolve_project_root(args.project_root)
955
- repo = resolve_project_repo(args.repo, project_root=project_root)
956
- # Fall back to the legacy CWD-scoped ``detect_repo`` only when no
957
- # project root could be inferred; that path still exists because
958
- # some in-process test suites monkeypatch ``detect_repo`` directly.
959
- if not repo:
960
- repo = detect_repo()
961
- if not repo:
962
- print(
963
- "Error: could not detect repo. "
964
- "Pass --repo OWNER/NAME, set $DEFT_PROJECT_REPO, or run from "
965
- "a directory tree whose git remote origin is the consumer "
966
- "repo (#538).",
967
- file=sys.stderr,
968
- )
969
- return 2
970
- repo_url = _resolve_repo_url(repo)
971
-
972
- if args.all:
973
- issues = fetch_open_issues(repo, cwd=project_root)
974
- if issues is None:
975
- return 2
976
- summary = ingest_bulk(
977
- issues,
978
- vbrief_dir=vbrief_dir,
979
- status=args.status,
980
- repo_url=repo_url,
981
- label=args.label,
982
- dry_run=args.dry_run,
983
- )
984
- print(
985
- "issue:ingest bulk summary: "
986
- f"{len(summary['created'])} created, "
987
- f"{len(summary['duplicate'])} duplicate, "
988
- f"{len(summary['dryrun'])} dry-run "
989
- f"(total considered: {summary['total']})"
990
- )
991
- for entry in summary["created"]:
992
- print(f" CREATED {entry}")
993
- for entry in summary["dryrun"]:
994
- print(f" DRY-RUN {entry}")
995
- for entry in summary["duplicate"]:
996
- print(f" SKIP {entry} (already has scope vBRIEF)")
997
- return 0
998
-
999
- # Single-issue mode -- prefer the unified cache (#883/#988) before
1000
- # falling back to a live ``gh api`` round-trip.
1001
- issue = _fetch_issue(repo, args.number, cwd=project_root)
1002
- if issue is None:
1003
- return 2
1004
- result, path, msg = ingest_one(
1005
- issue,
1006
- vbrief_dir=vbrief_dir,
1007
- status=args.status,
1008
- repo_url=repo_url,
1009
- dry_run=args.dry_run,
1010
- )
1011
- print(msg)
1012
- if result == "duplicate":
1013
- return 1
1014
- return 0
1015
-
1016
-
1017
- def ingest_single_for_accept(
1018
- n: int,
1019
- repo: str,
1020
- *,
1021
- project_root: Path | None = None,
1022
- status: str = "proposed",
1023
- cache_root: Path | None = None,
1024
- ) -> tuple[str, Path | None]:
1025
- """Ingest a single issue on behalf of ``triage_actions.accept`` (#985).
1026
-
1027
- The triage skill's contract is that ``task triage:accept`` delegates the
1028
- actual vBRIEF authoring to ``task issue:ingest`` so slug / reference /
1029
- schema rules stay in one place (per ``conventions/references.md`` and
1030
- ``skills/deft-directive-refinement/SKILL.md`` Phase 0 Tier 3). This is
1031
- the importable Python entry point that ``scripts/triage_actions.py::accept``
1032
- calls after the audit-log append succeeds.
1033
-
1034
- Resolves ``vbrief_dir`` to ``<project_root>/vbrief`` (created on demand)
1035
- and the ``repo_url`` to the canonical browser URL via
1036
- :func:`_resolve_repo_url`. Fetches the issue via :func:`_fetch_issue`
1037
- (cache-first per #988) and writes the vBRIEF via :func:`ingest_one`.
1038
-
1039
- Returns the ``(result, path)`` tuple from :func:`ingest_one`. Raises
1040
- :class:`RuntimeError` on fetch failure so the caller (``accept``) can
1041
- roll the audit-log entry back.
1042
- """
1043
- root = (project_root or Path.cwd()).resolve()
1044
- vbrief_dir = (root / "vbrief").resolve()
1045
- if not vbrief_dir.exists():
1046
- vbrief_dir.mkdir(parents=True, exist_ok=True)
1047
- repo_url = _resolve_repo_url(repo)
1048
- issue = _fetch_issue(repo, n, cwd=root, cache_root=cache_root)
1049
- if issue is None:
1050
- raise RuntimeError(
1051
- f"failed to fetch GitHub issue #{n} from {repo} "
1052
- "(unified cache miss + live gh api fetch failed; see stderr)"
1053
- )
1054
- result, path, _msg = ingest_one(
1055
- issue,
1056
- vbrief_dir=vbrief_dir,
1057
- status=status,
1058
- repo_url=repo_url,
1059
- )
1060
- return result, path
1061
-
1062
-
1063
- if __name__ == "__main__":
1064
- raise SystemExit(main())