@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,670 +0,0 @@
1
- #!/usr/bin/env python3
2
- r"""issue_emit.py -- Emit GitHub issues FROM scope vBRIEFs (the write path).
3
-
4
- This is the symmetric reverse of :mod:`scripts.issue_ingest`. Where
5
- ``task issue:ingest`` reads a GitHub issue and materialises a scope vBRIEF,
6
- ``task issue:emit`` reads one or more scope vBRIEFs and files GitHub
7
- issue(s), then records the resulting issue URL back into each source
8
- vBRIEF's ``plan.references[]`` as an ``x-vbrief/github-issue`` entry with
9
- ``TrustLevel: external``. Together the two verbs close the
10
- vBRIEF <-> GitHub-issue trust loop (#1274 Change 2 / epic #1284).
11
-
12
- Modes:
13
- uv run python scripts/issue_emit.py <vbrief-path>
14
- File ONE issue for the named vBRIEF and write the URL back into it.
15
- uv run python scripts/issue_emit.py --umbrella <glob> [<glob> ...]
16
- File ONE umbrella issue with a checklist of the matched vBRIEFs and
17
- write the umbrella URL back into EVERY matched vBRIEF.
18
- uv run python scripts/issue_emit.py --per-vbrief <glob> [<glob> ...]
19
- File one issue per matched vBRIEF.
20
-
21
- Flags:
22
- --dry-run Print the plan of issues that WOULD be filed; make no
23
- forge write and no on-disk vBRIEF mutation.
24
- --repo OWNER/REPO Target repo (highest precedence; falls back to
25
- $DEFT_PROJECT_REPO / git remote detection).
26
- --project-root Consumer project root for repo / glob anchoring.
27
- --title Umbrella issue title override (--umbrella mode only).
28
- --json Emit a machine-readable JSON summary instead of prose.
29
-
30
- Network honour:
31
- ``DEFT_NO_NETWORK=1`` is treated identically to ``--dry-run`` -- a plan
32
- is printed, no issue is filed, and no source vBRIEF is mutated on disk.
33
-
34
- Idempotency:
35
- A source vBRIEF that already carries a matching ``x-vbrief/github-issue``
36
- reference is detected and skipped rather than re-filed, so a re-run does
37
- not create duplicate issues.
38
-
39
- All ``gh`` invocations route through :func:`scripts.scm.call` (#1145 / N5)
40
- so the SCM boundary holds; the issue body is passed via
41
- ``gh issue create --body-file`` (written as pathlib UTF-8) rather than an
42
- inline ``--body`` so non-ASCII narrative glyphs survive the round-trip.
43
-
44
- Exit codes:
45
- 0 -- emit completed (including dry-run / no-network plans and pure-skip)
46
- 2 -- usage / configuration / forge error
47
-
48
- Story: #1274 Change 2 (task issue:emit); epic #1284.
49
- """
50
-
51
- from __future__ import annotations
52
-
53
- import argparse
54
- import contextlib
55
- import glob as globlib
56
- import json
57
- import os
58
- import re
59
- import sys
60
- import tempfile
61
- from collections.abc import Callable
62
- from pathlib import Path
63
- from typing import Any
64
-
65
- # Make sibling scripts importable both when run as __main__ and when imported
66
- # by tests that pre-populate sys.path with the ``scripts/`` directory.
67
- sys.path.insert(0, str(Path(__file__).resolve().parent))
68
-
69
- import scm # noqa: E402 -- sibling-first path insertion above is intentional
70
- from _project_context import resolve_project_repo, resolve_project_root # noqa: E402
71
- from _stdio_utf8 import reconfigure_stdio # noqa: E402
72
-
73
- reconfigure_stdio()
74
-
75
- # --- Constants --------------------------------------------------------------
76
-
77
- #: Canonical reference type recorded on a source vBRIEF after a successful
78
- #: emit. Matches ``conventions/references.md`` and the
79
- #: ``EXTERNAL_REFERENCE_TYPES`` set in ``scripts/_vbrief_build.py``.
80
- GITHUB_ISSUE_REF_TYPE = "x-vbrief/github-issue"
81
-
82
- #: TrustLevel stamped on the emitted reference. An issue filed from a
83
- #: vBRIEF lives on the external forge, so it is ``external`` (the same
84
- #: default ``reference_with_default_trust`` would assign).
85
- EXTERNAL_TRUST_LEVEL = "external"
86
-
87
- #: Extracts the browser issue URL from ``gh issue create`` stdout. gh prints
88
- #: the created issue URL (e.g. ``https://github.com/o/r/issues/42``) as the
89
- #: final line of stdout on success.
90
- _ISSUE_URL_RE = re.compile(r"https?://\S+?/issues/\d+")
91
-
92
-
93
- class IssueEmitError(RuntimeError):
94
- """Raised when filing a GitHub issue through the scm shim fails."""
95
-
96
-
97
- # --- vBRIEF helpers ---------------------------------------------------------
98
-
99
-
100
- def load_vbrief(path: Path) -> dict:
101
- """Read and parse a vBRIEF JSON file (UTF-8)."""
102
- data: Any = json.loads(path.read_text(encoding="utf-8"))
103
- return data if isinstance(data, dict) else {}
104
-
105
-
106
- def write_vbrief(path: Path, data: dict) -> None:
107
- """Write a vBRIEF dict back to disk as pretty-printed UTF-8 JSON.
108
-
109
- Uses ``ensure_ascii=False`` so non-ASCII narrative glyphs round-trip as
110
- real UTF-8 bytes (``task verify:encoding`` flags mojibake / BOM, and a
111
- locale-default write would risk both on Windows hosts).
112
- """
113
- path.write_text(
114
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
115
- encoding="utf-8",
116
- )
117
-
118
-
119
- def vbrief_title(data: dict) -> str:
120
- """Resolve a human title for a vBRIEF.
121
-
122
- Prefers ``plan.title``; falls back to ``vBRIEFInfo.description`` and then
123
- a generic placeholder so a malformed vBRIEF still produces a usable issue
124
- title rather than an empty string.
125
- """
126
- plan = data.get("plan", {}) if isinstance(data, dict) else {}
127
- title = plan.get("title") if isinstance(plan, dict) else None
128
- if isinstance(title, str) and title.strip():
129
- return title.strip()
130
- info = data.get("vBRIEFInfo", {}) if isinstance(data, dict) else {}
131
- desc = info.get("description", "") if isinstance(info, dict) else ""
132
- if isinstance(desc, str) and desc.strip():
133
- return desc.strip()
134
- return "Untitled vBRIEF"
135
-
136
-
137
- def existing_github_issue_ref(data: dict) -> str | None:
138
- """Return the first ``x-vbrief/github-issue`` reference URI, or None.
139
-
140
- Used for idempotency: a vBRIEF that already carries a github-issue
141
- reference has already been tracked, so ``emit`` skips it instead of
142
- filing a duplicate. Returns the empty string when a matching reference
143
- exists but carries no URI (still a positive "already tracked" signal).
144
- """
145
- if not isinstance(data, dict):
146
- return None
147
- plan = data.get("plan", {})
148
- refs = plan.get("references", []) if isinstance(plan, dict) else []
149
- for ref in refs:
150
- if isinstance(ref, dict) and ref.get("type") == GITHUB_ISSUE_REF_TYPE:
151
- uri = ref.get("uri") or ref.get("url")
152
- return uri if isinstance(uri, str) and uri else ""
153
- return None
154
-
155
-
156
- def add_github_issue_reference(data: dict, url: str) -> dict:
157
- """Append an external github-issue reference to ``plan.references[]``.
158
-
159
- Mutates ``data`` in place (and returns it for convenience). The appended
160
- entry is the canonical ``{uri, type, TrustLevel}`` shape required by the
161
- #1274 acceptance criteria.
162
- """
163
- plan = data.setdefault("plan", {})
164
- refs = plan.setdefault("references", [])
165
- refs.append(
166
- {
167
- "uri": url,
168
- "type": GITHUB_ISSUE_REF_TYPE,
169
- "TrustLevel": EXTERNAL_TRUST_LEVEL,
170
- }
171
- )
172
- return data
173
-
174
-
175
- # --- Issue body rendering ---------------------------------------------------
176
-
177
-
178
- def render_issue_body(data: dict) -> str:
179
- """Render a GitHub issue body from a vBRIEF's narratives.
180
-
181
- Sections, in order: Description, Acceptance (plan-level narrative plus
182
- per-item ``narrative.Acceptance`` bullets), and Traces. Empty sections
183
- are omitted. A vBRIEF with no usable narrative still yields a non-empty
184
- body naming the scope so ``gh issue create`` never receives an empty
185
- ``--body-file``.
186
- """
187
- plan = data.get("plan", {}) if isinstance(data, dict) else {}
188
- narratives = plan.get("narratives", {}) if isinstance(plan, dict) else {}
189
- if not isinstance(narratives, dict):
190
- narratives = {}
191
-
192
- parts: list[str] = []
193
-
194
- desc = narratives.get("Description")
195
- if isinstance(desc, str) and desc.strip():
196
- parts.append("## Description\n\n" + desc.strip())
197
-
198
- acceptance_lines: list[str] = []
199
- plan_acceptance = narratives.get("Acceptance")
200
- if isinstance(plan_acceptance, str) and plan_acceptance.strip():
201
- acceptance_lines.append(plan_acceptance.strip())
202
- for item in plan.get("items", []) if isinstance(plan, dict) else []:
203
- if not isinstance(item, dict):
204
- continue
205
- item_narrative = item.get("narrative", {})
206
- acc = item_narrative.get("Acceptance") if isinstance(item_narrative, dict) else None
207
- if isinstance(acc, str) and acc.strip():
208
- item_title = str(item.get("title", "")).strip()
209
- if item_title:
210
- acceptance_lines.append(f"- **{item_title}**: {acc.strip()}")
211
- else:
212
- acceptance_lines.append(f"- {acc.strip()}")
213
- if acceptance_lines:
214
- parts.append("## Acceptance\n\n" + "\n".join(acceptance_lines))
215
-
216
- traces = narratives.get("Traces")
217
- if isinstance(traces, str) and traces.strip():
218
- parts.append("## Traces\n\n" + traces.strip())
219
-
220
- if not parts:
221
- return f"Scope vBRIEF: {vbrief_title(data)}\n"
222
- return "\n\n".join(parts) + "\n"
223
-
224
-
225
- def render_umbrella_body(entries: list[tuple[str, dict]], *, intro: str | None = None) -> str:
226
- """Render an umbrella issue body with a checklist of tracked vBRIEFs.
227
-
228
- ``entries`` is a list of ``(relative-or-display-path, vbrief-data)``
229
- tuples. Each becomes an unchecked task-list item naming the vBRIEF title
230
- and its path so the umbrella reads as a roadmap.
231
- """
232
- lines: list[str] = []
233
- if intro:
234
- lines.append(intro.strip())
235
- lines.append("")
236
- lines.append("## Tracked vBRIEFs")
237
- lines.append("")
238
- for display_path, data in entries:
239
- lines.append(f"- [ ] {vbrief_title(data)} (`{display_path}`)")
240
- return "\n".join(lines) + "\n"
241
-
242
-
243
- # --- Forge interaction ------------------------------------------------------
244
-
245
-
246
- def file_issue(
247
- repo: str,
248
- title: str,
249
- body: str,
250
- *,
251
- scm_call: Callable[..., Any] | None = None,
252
- ) -> str:
253
- """File a single GitHub issue via the scm shim and return its URL.
254
-
255
- The body is written to a temporary UTF-8 file and passed through
256
- ``gh issue create --body-file`` so non-ASCII glyphs survive (an inline
257
- ``--body`` risks codepage corruption on Windows hosts). The call routes
258
- through :func:`scripts.scm.call` with ``source="github-issue"`` so the
259
- #1145 SCM boundary holds, and forces ``encoding="utf-8",
260
- errors="replace"`` per the #1366 safe-capture rule.
261
-
262
- Raises :class:`IssueEmitError` on a non-zero exit or when no issue URL
263
- can be parsed from stdout.
264
- """
265
- # Resolve the binding at call time (not as a default arg) so tests that
266
- # monkeypatch ``issue_emit.scm.call`` take effect.
267
- if scm_call is None:
268
- scm_call = scm.call
269
- fd, tmp_name = tempfile.mkstemp(suffix=".md", prefix="deft-issue-emit-")
270
- os.close(fd)
271
- body_path = Path(tmp_name)
272
- try:
273
- body_path.write_text(body, encoding="utf-8")
274
- result = scm_call(
275
- "github-issue",
276
- "issue",
277
- [
278
- "create",
279
- "--repo",
280
- repo,
281
- "--title",
282
- title,
283
- "--body-file",
284
- str(body_path),
285
- ],
286
- timeout=60,
287
- encoding="utf-8",
288
- errors="replace",
289
- )
290
- finally:
291
- with contextlib.suppress(OSError):
292
- body_path.unlink()
293
-
294
- if result.returncode != 0:
295
- stderr = (result.stderr or "").strip()
296
- raise IssueEmitError(f"gh issue create failed (exit {result.returncode}): {stderr}")
297
- stdout = (result.stdout or "").strip()
298
- match = _ISSUE_URL_RE.search(stdout)
299
- if match:
300
- return match.group(0)
301
- if stdout:
302
- return stdout
303
- raise IssueEmitError("gh issue create succeeded but emitted no issue URL on stdout")
304
-
305
-
306
- # --- Emit actions -----------------------------------------------------------
307
-
308
-
309
- def emit_single(
310
- path: Path,
311
- *,
312
- repo: str,
313
- scm_call: Callable[..., Any] | None = None,
314
- no_network: bool = False,
315
- display_path: str | None = None,
316
- ) -> dict:
317
- """File one issue for a single vBRIEF and write the URL back into it.
318
-
319
- Returns an action dict with ``result`` one of ``"created"`` /
320
- ``"dryrun"`` / ``"skipped"``. ``no_network`` (dry-run or DEFT_NO_NETWORK)
321
- prints nothing here -- it returns a ``"dryrun"`` action and makes no
322
- forge write and no on-disk mutation. A vBRIEF that already carries a
323
- github-issue reference returns ``"skipped"`` (idempotency).
324
- """
325
- shown = display_path or str(path)
326
- data = load_vbrief(path)
327
- existing = existing_github_issue_ref(data)
328
- if existing is not None:
329
- return {
330
- "result": "skipped",
331
- "vbrief": shown,
332
- "url": existing or None,
333
- "title": vbrief_title(data),
334
- }
335
-
336
- title = vbrief_title(data)
337
- if no_network:
338
- return {
339
- "result": "dryrun",
340
- "vbrief": shown,
341
- "url": None,
342
- "title": title,
343
- }
344
-
345
- body = render_issue_body(data)
346
- url = file_issue(repo, title, body, scm_call=scm_call)
347
- add_github_issue_reference(data, url)
348
- write_vbrief(path, data)
349
- return {"result": "created", "vbrief": shown, "url": url, "title": title}
350
-
351
-
352
- def emit_per_vbrief(
353
- paths: list[Path],
354
- *,
355
- repo: str,
356
- scm_call: Callable[..., Any] | None = None,
357
- no_network: bool = False,
358
- display_paths: list[str] | None = None,
359
- ) -> list[dict]:
360
- """File one issue per matched vBRIEF (delegates to :func:`emit_single`)."""
361
- shown = display_paths or [str(p) for p in paths]
362
- actions: list[dict] = []
363
- for path, disp in zip(paths, shown, strict=True):
364
- actions.append(
365
- emit_single(
366
- path,
367
- repo=repo,
368
- scm_call=scm_call,
369
- no_network=no_network,
370
- display_path=disp,
371
- )
372
- )
373
- return actions
374
-
375
-
376
- def emit_umbrella(
377
- paths: list[Path],
378
- *,
379
- repo: str,
380
- scm_call: Callable[..., Any] | None = None,
381
- no_network: bool = False,
382
- title: str | None = None,
383
- display_paths: list[str] | None = None,
384
- ) -> dict:
385
- """File ONE umbrella issue tracking the matched vBRIEFs.
386
-
387
- Writes the umbrella URL back into every matched vBRIEF that does not
388
- already carry a github-issue reference. When EVERY matched vBRIEF is
389
- already tracked, the umbrella is treated as a no-op (``"skipped"``) so a
390
- re-run does not file a duplicate roadmap issue.
391
-
392
- Returns an action dict with ``result`` one of ``"created"`` /
393
- ``"dryrun"`` / ``"skipped"`` and a ``vbriefs`` list of per-file outcomes.
394
- """
395
- shown = display_paths or [str(p) for p in paths]
396
- loaded: list[tuple[Path, str, dict]] = []
397
- for path, disp in zip(paths, shown, strict=True):
398
- loaded.append((path, disp, load_vbrief(path)))
399
-
400
- pending = [
401
- (path, disp, data) for path, disp, data in loaded if existing_github_issue_ref(data) is None
402
- ]
403
- already = [
404
- {"vbrief": disp, "result": "skipped"}
405
- for path, disp, data in loaded
406
- if existing_github_issue_ref(data) is not None
407
- ]
408
-
409
- umbrella_title = title or _default_umbrella_title(loaded)
410
-
411
- if not pending:
412
- return {
413
- "result": "skipped",
414
- "url": None,
415
- "title": umbrella_title,
416
- "vbriefs": already,
417
- }
418
-
419
- if no_network:
420
- return {
421
- "result": "dryrun",
422
- "url": None,
423
- "title": umbrella_title,
424
- "vbriefs": [{"vbrief": disp, "result": "dryrun"} for _path, disp, _data in pending]
425
- + already,
426
- }
427
-
428
- body = render_umbrella_body([(disp, data) for _path, disp, data in pending])
429
- url = file_issue(repo, umbrella_title, body, scm_call=scm_call)
430
-
431
- written: list[dict] = []
432
- for path, disp, data in pending:
433
- add_github_issue_reference(data, url)
434
- write_vbrief(path, data)
435
- written.append({"vbrief": disp, "result": "created"})
436
-
437
- return {
438
- "result": "created",
439
- "url": url,
440
- "title": umbrella_title,
441
- "vbriefs": written + already,
442
- }
443
-
444
-
445
- def _default_umbrella_title(loaded: list[tuple[Path, str, dict]]) -> str:
446
- """Synthesise an umbrella title when the caller did not supply one."""
447
- count = len(loaded)
448
- noun = "vBRIEF" if count == 1 else "vBRIEFs"
449
- return f"Umbrella: {count} tracked {noun}"
450
-
451
-
452
- # --- Path expansion ---------------------------------------------------------
453
-
454
-
455
- def expand_patterns(patterns: list[str], *, root: Path | None = None) -> list[Path]:
456
- """Expand glob ``patterns`` into a de-duplicated, ordered list of paths.
457
-
458
- Patterns are resolved relative to ``root`` (the project root) when given
459
- and not already absolute. A pattern with no glob matches that names an
460
- existing file is taken literally so ``emit <one-file>`` works without a
461
- wildcard. Document order is preserved; duplicates are dropped.
462
- """
463
- seen: set[str] = set()
464
- out: list[Path] = []
465
- for pattern in patterns:
466
- candidate = pattern
467
- if root is not None and not os.path.isabs(pattern):
468
- candidate = str(root / pattern)
469
- matches = sorted(globlib.glob(candidate))
470
- if not matches and Path(candidate).exists():
471
- matches = [candidate]
472
- for match in matches:
473
- resolved = str(Path(match).resolve())
474
- if resolved in seen:
475
- continue
476
- seen.add(resolved)
477
- out.append(Path(match))
478
- return out
479
-
480
-
481
- # --- CLI --------------------------------------------------------------------
482
-
483
-
484
- def build_parser() -> argparse.ArgumentParser:
485
- parser = argparse.ArgumentParser(
486
- description=(
487
- "File GitHub issue(s) from scope vBRIEFs and record the issue "
488
- "URL back into each vBRIEF's references[] (#1274 Change 2)."
489
- ),
490
- )
491
- parser.add_argument(
492
- "patterns",
493
- nargs="*",
494
- help="vBRIEF path (single mode) or glob(s) (--umbrella / --per-vbrief)",
495
- )
496
- mode = parser.add_mutually_exclusive_group()
497
- mode.add_argument(
498
- "--umbrella",
499
- action="store_true",
500
- help="File ONE umbrella issue with a checklist of matched vBRIEFs",
501
- )
502
- mode.add_argument(
503
- "--per-vbrief",
504
- action="store_true",
505
- help="File one issue per matched vBRIEF",
506
- )
507
- parser.add_argument(
508
- "--title",
509
- default=None,
510
- help="Umbrella issue title (--umbrella mode only)",
511
- )
512
- parser.add_argument(
513
- "--dry-run",
514
- action="store_true",
515
- help="Print the plan without filing issues or mutating vBRIEFs",
516
- )
517
- parser.add_argument(
518
- "--json",
519
- action="store_true",
520
- help="Emit a machine-readable JSON summary instead of prose",
521
- )
522
- parser.add_argument(
523
- "--repo",
524
- default=None,
525
- help="GitHub repo OWNER/REPO (highest precedence; beats env / git remote)",
526
- )
527
- parser.add_argument(
528
- "--project-root",
529
- default=None,
530
- help="Consumer project root used for repo detection and glob anchoring",
531
- )
532
- return parser
533
-
534
-
535
- def _is_no_network(dry_run: bool) -> bool:
536
- """Return True when network access is disabled (dry-run or env opt-out)."""
537
- return dry_run or os.environ.get("DEFT_NO_NETWORK") == "1"
538
-
539
-
540
- def _print_summary(summary: dict, *, as_json: bool) -> None:
541
- if as_json:
542
- print(json.dumps(summary, ensure_ascii=False, indent=2))
543
- return
544
- mode = summary["mode"]
545
- no_network = summary["no_network"]
546
- banner = "issue:emit plan (no network)" if no_network else "issue:emit"
547
- print(f"{banner} -- mode: {mode}")
548
- if mode == "umbrella":
549
- action = summary["umbrella"]
550
- verb = {
551
- "created": "FILED umbrella",
552
- "dryrun": "WOULD FILE umbrella",
553
- "skipped": "SKIP umbrella (already tracked)",
554
- }[action["result"]]
555
- url = f" -> {action['url']}" if action.get("url") else ""
556
- print(f" {verb}: {action['title']}{url}")
557
- for child in action["vbriefs"]:
558
- print(f" - {child['result'].upper():8} {child['vbrief']}")
559
- else:
560
- for action in summary["actions"]:
561
- verb = {
562
- "created": "FILED",
563
- "dryrun": "WOULD FILE",
564
- "skipped": "SKIP (already tracked)",
565
- }[action["result"]]
566
- url = f" -> {action['url']}" if action.get("url") else ""
567
- print(f" {verb:22} {action['vbrief']}{url}")
568
-
569
-
570
- def main(argv: list[str] | None = None) -> int:
571
- parser = build_parser()
572
- args = parser.parse_args(argv)
573
-
574
- if not args.patterns:
575
- parser.error("Provide a vBRIEF path or glob(s) to emit")
576
-
577
- if args.title and not args.umbrella:
578
- parser.error("--title is only valid with --umbrella")
579
-
580
- project_root = resolve_project_root(args.project_root)
581
- paths = expand_patterns(args.patterns, root=project_root)
582
- if not paths:
583
- print(
584
- f"Error: no vBRIEF files matched {args.patterns!r}.",
585
- file=sys.stderr,
586
- )
587
- return 2
588
-
589
- no_network = _is_no_network(args.dry_run)
590
-
591
- # Repo is only required for a real (network) filing. Dry-run / no-network
592
- # plans never call the forge, so a missing repo is not fatal there.
593
- repo = ""
594
- if not no_network:
595
- repo = resolve_project_repo(args.repo, project_root=project_root) or ""
596
- if not repo:
597
- print(
598
- "Error: could not detect repo. Pass --repo OWNER/NAME, set "
599
- "$DEFT_PROJECT_REPO, or run from the consumer repo (#538).",
600
- file=sys.stderr,
601
- )
602
- return 2
603
-
604
- display = [_display_path(p, project_root) for p in paths]
605
-
606
- try:
607
- if args.umbrella:
608
- action = emit_umbrella(
609
- paths,
610
- repo=repo,
611
- no_network=no_network,
612
- title=args.title,
613
- display_paths=display,
614
- )
615
- summary = {
616
- "mode": "umbrella",
617
- "no_network": no_network,
618
- "umbrella": action,
619
- }
620
- elif args.per_vbrief:
621
- actions = emit_per_vbrief(
622
- paths,
623
- repo=repo,
624
- no_network=no_network,
625
- display_paths=display,
626
- )
627
- summary = {
628
- "mode": "per-vbrief",
629
- "no_network": no_network,
630
- "actions": actions,
631
- }
632
- else:
633
- if len(paths) != 1:
634
- print(
635
- "Error: single mode expects exactly one vBRIEF; matched "
636
- f"{len(paths)}. Use --umbrella or --per-vbrief for globs.",
637
- file=sys.stderr,
638
- )
639
- return 2
640
- action = emit_single(
641
- paths[0],
642
- repo=repo,
643
- no_network=no_network,
644
- display_path=display[0],
645
- )
646
- summary = {
647
- "mode": "single",
648
- "no_network": no_network,
649
- "actions": [action],
650
- }
651
- except IssueEmitError as exc:
652
- print(f"Error: {exc}", file=sys.stderr)
653
- return 2
654
-
655
- _print_summary(summary, as_json=args.json)
656
- return 0
657
-
658
-
659
- def _display_path(path: Path, project_root: Path | None) -> str:
660
- """Return ``path`` relative to the project root when possible."""
661
- if project_root is not None:
662
- try:
663
- return str(path.resolve().relative_to(project_root.resolve()))
664
- except ValueError:
665
- pass
666
- return str(path)
667
-
668
-
669
- if __name__ == "__main__":
670
- raise SystemExit(main())