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