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