@deftai/directive-content 0.59.0 → 0.60.0

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