@deftai/directive-content 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,575 +0,0 @@
1
- #!/usr/bin/env python3
2
- """triage_scope_drift.py -- subscription drift detection (D14 / #1133).
3
-
4
- Walks the unified ``.deft-cache/github-issue/<owner>/<repo>/<N>/raw.json``
5
- mirror (#883 Story 2) and computes:
6
-
7
- * ``unsubscribed-labels``: labels appearing on >= ``_DRIFT_MIN_ISSUES``
8
- cached issues whose latest state is ``open`` AND that are NOT covered
9
- by any active ``plan.policy.triageScope[]`` rule.
10
- * ``unsubscribed-milestones``: milestones with >= ``_DRIFT_MIN_ISSUES``
11
- open cached issues NOT covered by any ``milestone`` rule (D14 / #1133
12
- v1 exact-match shape).
13
-
14
- The threshold is a framework constant at module top per umbrella #1119
15
- section 12 framework-vs-consumer boundary; consumer tunability (e.g.
16
- ``plan.policy.driftMinIssues``) is explicitly v2 scope.
17
-
18
- Entries that the operator has explicitly chosen to ignore via
19
- ``plan.policy.triageScopeIgnores[]`` are suppressed from the surfaced
20
- counts AND from the rendered output (D14c / #1182 will introduce
21
- sunset-on / mass-edit tuning verbs on top of this foundation).
22
-
23
- Public surface:
24
-
25
- * :data:`_DRIFT_MIN_ISSUES` -- the v1 threshold (3).
26
- * :class:`DriftReport` -- frozen dataclass with per-signal counts and
27
- the total surfaced issue count (the number D2's one-liner segment
28
- consumes).
29
- * :func:`compute_drift` -- read-only computation; never mutates state.
30
- * :func:`render_drift_report` -- human-readable rendering of a report.
31
- * :func:`add_ignore` -- atomic mutation that appends a
32
- ``{label|milestone: <name>}`` entry to
33
- ``plan.policy.triageScopeIgnores[]``.
34
-
35
- CLI shim lives at ``scripts/_triage_scope_drift_cli.py`` so this module
36
- stays under the 1000-line MUST cap from ``coding/coding.md``.
37
- """
38
-
39
- from __future__ import annotations
40
-
41
- import contextlib
42
- import json
43
- import sys
44
- from dataclasses import dataclass, field
45
- from pathlib import Path
46
- from typing import Any
47
-
48
- # Sibling imports
49
- sys.path.insert(0, str(Path(__file__).resolve().parent))
50
-
51
- # UTF-8 self-reconfigure (mirrors triage_scope.py / triage_summary.py).
52
- for _stream in (sys.stdout, sys.stderr):
53
- if hasattr(_stream, "reconfigure"):
54
- with contextlib.suppress(AttributeError, ValueError):
55
- _stream.reconfigure(encoding="utf-8", errors="replace")
56
-
57
-
58
- # ---------------------------------------------------------------------------
59
- # Constants
60
- # ---------------------------------------------------------------------------
61
-
62
- #: Framework drift-threshold (D14 / #1133). A label or milestone is
63
- #: surfaced as drift only if at least this many currently-open cached
64
- #: issues carry it AND it is not covered by the active subscription.
65
- #: The constant lives here so future tunability (``plan.policy.driftMinIssues``,
66
- #: v2 scope) has a single source of truth to override.
67
- _DRIFT_MIN_ISSUES: int = 3
68
-
69
- #: Cache directory + source name. Mirrors ``triage_summary.CACHE_DIR_NAME``
70
- #: + ``CACHE_SOURCE`` so the drift detector reads the same layout the
71
- #: summary verb consumes.
72
- CACHE_DIR_NAME = ".deft-cache"
73
- CACHE_SOURCE = "github-issue"
74
-
75
-
76
- # ---------------------------------------------------------------------------
77
- # Dataclasses
78
- # ---------------------------------------------------------------------------
79
-
80
-
81
- @dataclass(frozen=True)
82
- class DriftReport:
83
- """Structured drift report.
84
-
85
- Two parallel mappings (label/milestone -> issue count) plus the
86
- aggregate ``total`` that D2's ``[scope-drift] N`` segment renders.
87
- The total equals the number of distinct open cached issues that
88
- would join the subscription if every surfaced signal were opted
89
- into (NOT the sum of counts: an issue with two unsubscribed labels
90
- counts once).
91
- """
92
-
93
- labels: dict[str, int] = field(default_factory=dict)
94
- milestones: dict[str, int] = field(default_factory=dict)
95
- total: int = 0
96
- threshold: int = _DRIFT_MIN_ISSUES
97
-
98
- def is_empty(self) -> bool:
99
- """True when neither labels nor milestones have any surfaced drift."""
100
- return not self.labels and not self.milestones
101
-
102
-
103
- # ---------------------------------------------------------------------------
104
- # Cache walker
105
- # ---------------------------------------------------------------------------
106
-
107
-
108
- def _iter_cache_issues(cache_root: Path) -> list[dict[str, Any]]:
109
- """Walk ``<cache_root>/github-issue/<owner>/<repo>/<N>/raw.json``.
110
-
111
- Returns the list of raw GitHub-issue payloads (each a dict). Bad /
112
- missing files are silently skipped -- the drift detector MUST NOT
113
- crash on a torn cache, mirroring the tolerance contract in
114
- ``triage_summary.read_audit_log``.
115
- """
116
- base = cache_root / CACHE_SOURCE
117
- if not base.is_dir():
118
- return []
119
- out: list[dict[str, Any]] = []
120
- for owner_dir in sorted(base.iterdir(), key=lambda p: p.name):
121
- if not owner_dir.is_dir():
122
- continue
123
- for repo_dir in sorted(owner_dir.iterdir(), key=lambda p: p.name):
124
- if not repo_dir.is_dir():
125
- continue
126
- for issue_dir in sorted(repo_dir.iterdir(), key=lambda p: p.name):
127
- if not issue_dir.is_dir() or not issue_dir.name.isdecimal():
128
- continue
129
- raw_path = issue_dir / "raw.json"
130
- if not raw_path.is_file():
131
- continue
132
- try:
133
- data = json.loads(raw_path.read_text(encoding="utf-8"))
134
- except (json.JSONDecodeError, OSError, UnicodeDecodeError):
135
- continue
136
- if isinstance(data, dict):
137
- out.append(data)
138
- return out
139
-
140
-
141
- def _extract_labels(issue: dict[str, Any]) -> set[str]:
142
- raw = issue.get("labels")
143
- if not isinstance(raw, list):
144
- return set()
145
- names: set[str] = set()
146
- for item in raw:
147
- if isinstance(item, dict):
148
- name = item.get("name")
149
- if isinstance(name, str) and name:
150
- names.add(name)
151
- elif isinstance(item, str) and item:
152
- names.add(item)
153
- return names
154
-
155
-
156
- def _extract_milestone(issue: dict[str, Any]) -> str:
157
- raw = issue.get("milestone")
158
- if isinstance(raw, dict):
159
- title = raw.get("title")
160
- if isinstance(title, str) and title:
161
- return title
162
- alt = raw.get("name")
163
- if isinstance(alt, str) and alt:
164
- return alt
165
- elif isinstance(raw, str) and raw:
166
- return raw
167
- return ""
168
-
169
-
170
- def _extract_author(issue: dict[str, Any]) -> str:
171
- """Return the cached issue's author login (D14c / #1182).
172
-
173
- GitHub REST issues payload shapes the author as
174
- ``{ "user": { "login": "<name>", ... }, ... }``. We tolerate the
175
- bare-string and ``{author: <str>}`` shapes for fixture flexibility.
176
- Returns ``""`` (never ``None``) so downstream membership checks
177
- stay type-safe.
178
- """
179
- user = issue.get("user")
180
- if isinstance(user, dict):
181
- login = user.get("login")
182
- if isinstance(login, str) and login:
183
- return login
184
- author = issue.get("author")
185
- if isinstance(author, dict):
186
- login = author.get("login")
187
- if isinstance(login, str) and login:
188
- return login
189
- if isinstance(author, str) and author:
190
- return author
191
- return ""
192
-
193
-
194
- def _is_open(issue: dict[str, Any]) -> bool:
195
- return issue.get("state", "open") == "open"
196
-
197
-
198
- # ---------------------------------------------------------------------------
199
- # Subscription coverage helpers
200
- # ---------------------------------------------------------------------------
201
-
202
-
203
- def _subscribed_labels(rules: list[dict[str, Any]]) -> set[str]:
204
- """Return the set of label names covered by any ``labels`` rule.
205
-
206
- Both ``any-of`` and ``all-of`` shapes contribute -- the question
207
- the drift detector asks is "does the subscription mention this
208
- label at all?", not "does the subscription match issues with this
209
- label?". A label appearing in ``all-of`` still suppresses drift
210
- because the operator obviously already knows about it.
211
- """
212
- out: set[str] = set()
213
- for rule in rules:
214
- if not isinstance(rule, dict) or rule.get("rule") != "labels":
215
- continue
216
- for key in ("any-of", "all-of"):
217
- value = rule.get(key)
218
- if isinstance(value, list):
219
- for label in value:
220
- if isinstance(label, str) and label:
221
- out.add(label)
222
- return out
223
-
224
-
225
- def _subscribed_milestones(
226
- rules: list[dict[str, Any]],
227
- *,
228
- open_milestones_snapshot: set[str] | None = None,
229
- ) -> set[str]:
230
- """Return milestone names covered by ``milestone`` rules.
231
-
232
- Recognises all three D14b (#1181) variants:
233
-
234
- * ``{name: "<n>"}`` -- single exact name (D14 v1).
235
- * ``{any-of: ["<n1>", ...]}`` -- explicit list.
236
- * ``{is-open: true}`` -- subscribes to whatever is currently open
237
- upstream; the caller pre-fetches the open snapshot and passes it
238
- in via ``open_milestones_snapshot`` so the drift detector
239
- consults the same set the evaluator does.
240
- """
241
- from _triage_scope_milestone import (
242
- collect_milestone_subscribed_names,
243
- rules_request_is_open,
244
- )
245
-
246
- out = collect_milestone_subscribed_names(rules)
247
- if rules_request_is_open(rules) and open_milestones_snapshot:
248
- out |= set(open_milestones_snapshot)
249
- return out
250
-
251
-
252
- # ---------------------------------------------------------------------------
253
- # Public API: compute / render / mutate
254
- # ---------------------------------------------------------------------------
255
-
256
-
257
- def compute_drift(
258
- project_root: Path,
259
- *,
260
- cache_root: Path | None = None,
261
- threshold: int | None = None,
262
- open_milestones_fetcher: Any = None,
263
- ) -> DriftReport:
264
- """Compute the drift report for a project.
265
-
266
- ``cache_root`` defaults to ``<project_root>/.deft-cache``.
267
- ``threshold`` defaults to :data:`_DRIFT_MIN_ISSUES`; passing an
268
- override is supported for tests but consumers SHOULD let the
269
- framework default stand (D14 / #1133 ships the threshold as a
270
- framework constant; per-consumer tunability is v2 scope).
271
-
272
- ``open_milestones_fetcher`` is the D14b (#1181) injection point:
273
- when any ``milestone {is-open: true}`` rule is present, the drift
274
- detector fetches the upstream open-milestones snapshot once and
275
- excludes those names from the surfaced drift. When omitted, the
276
- default ``gh api repos/<owner>/<name>/milestones?state=open``
277
- fetcher is used (best-effort; failures degrade to an empty
278
- snapshot per the evaluator's contract).
279
-
280
- Read-only: never mutates PROJECT-DEFINITION, the cache, or the
281
- audit log. Empty cache yields an empty report (``total == 0``).
282
- """
283
- from triage_scope import resolve_scope_ignores, resolve_scope_rules
284
-
285
- resolved_cache_root = cache_root or (project_root / CACHE_DIR_NAME)
286
- effective_threshold = (
287
- threshold if threshold is not None and threshold > 0 else _DRIFT_MIN_ISSUES
288
- )
289
-
290
- issues = _iter_cache_issues(resolved_cache_root)
291
- rules = resolve_scope_rules(project_root)
292
- ignores = resolve_scope_ignores(project_root)
293
-
294
- # D14b (#1181): resolve the open-milestones snapshot once when any
295
- # rule asks for ``is-open: true``; an unavailable snapshot degrades
296
- # to empty (drift still surfaces the milestone in that case so the
297
- # operator sees the network failure indirectly).
298
- open_ms_snapshot: set[str] = set()
299
- from _triage_scope_milestone import (
300
- default_open_milestones_fetcher,
301
- infer_repo_from_issues,
302
- rules_request_is_open,
303
- )
304
- if rules_request_is_open(rules):
305
- if open_milestones_fetcher is not None:
306
- try:
307
- raw = open_milestones_fetcher()
308
- except Exception: # noqa: BLE001
309
- raw = set()
310
- open_ms_snapshot = (
311
- set(raw)
312
- if isinstance(raw, (set, frozenset, list, tuple))
313
- else set()
314
- )
315
- else:
316
- inferred_repo = infer_repo_from_issues(issues)
317
- open_ms_snapshot = default_open_milestones_fetcher(inferred_repo)
318
-
319
- # `all-open` subscribes to every currently-open upstream issue by
320
- # definition (umbrella section 12 framework default when
321
- # ``plan.policy.triageScope[]`` is unset / missing). Under that
322
- # rule every cached open issue is already in scope, so no label
323
- # or milestone can be "unsubscribed" -- the drift detector would
324
- # otherwise spuriously flag every label/milestone on >=3 cached
325
- # open issues for the entire default-config consumer base.
326
- # Short-circuit to an empty report so D2's `[scope-drift] N`
327
- # segment stays suppressed (segment renders only when N > 0).
328
- if any(isinstance(r, dict) and r.get("rule") == "all-open" for r in rules):
329
- return DriftReport(threshold=effective_threshold)
330
-
331
- subscribed_labels = _subscribed_labels(rules)
332
- subscribed_milestones = _subscribed_milestones(
333
- rules, open_milestones_snapshot=open_ms_snapshot
334
- )
335
-
336
- label_counts: dict[str, int] = {}
337
- milestone_counts: dict[str, int] = {}
338
- # Track which issues are surfaced under any drift signal so
339
- # ``total`` counts distinct issues, not signal-occurrences.
340
- surfaced_issues: set[tuple[str, int]] = set()
341
- # D14c / #1182: issues whose `user.login` matches a
342
- # `{rule: author, any-of: [...]}` ignore entry are dropped from
343
- # the drift surface entirely -- the operator already told us they
344
- # don't care about this author's issues (canonical case: dependabot
345
- # / renovate noise on a consumer's repo).
346
- ignored_authors = ignores.get("authors", set())
347
-
348
- for issue in issues:
349
- if not _is_open(issue):
350
- continue
351
- number = issue.get("number")
352
- if not isinstance(number, int):
353
- continue
354
- if ignored_authors and _extract_author(issue) in ignored_authors:
355
- continue
356
- labels = _extract_labels(issue)
357
- for label in labels:
358
- if label in subscribed_labels or label in ignores["labels"]:
359
- continue
360
- label_counts[label] = label_counts.get(label, 0) + 1
361
- milestone = _extract_milestone(issue)
362
- if (
363
- milestone
364
- and milestone not in subscribed_milestones
365
- and milestone not in ignores["milestones"]
366
- ):
367
- milestone_counts[milestone] = milestone_counts.get(milestone, 0) + 1
368
-
369
- surfaced_labels = {
370
- label: count
371
- for label, count in label_counts.items()
372
- if count >= effective_threshold
373
- }
374
- surfaced_milestones = {
375
- name: count
376
- for name, count in milestone_counts.items()
377
- if count >= effective_threshold
378
- }
379
-
380
- # Re-walk to compute the distinct-issue total -- an issue counts
381
- # toward ``total`` if any of its labels / its milestone is surfaced.
382
- # Author-ignored issues are excluded here too so the total stays
383
- # consistent with the surfaced signals.
384
- for issue in issues:
385
- if not _is_open(issue):
386
- continue
387
- number = issue.get("number")
388
- if not isinstance(number, int):
389
- continue
390
- if ignored_authors and _extract_author(issue) in ignored_authors:
391
- continue
392
- repo_key = _issue_repo_key(issue)
393
- labels = _extract_labels(issue)
394
- milestone = _extract_milestone(issue)
395
- if any(label in surfaced_labels for label in labels) or (
396
- milestone and milestone in surfaced_milestones
397
- ):
398
- surfaced_issues.add((repo_key, number))
399
-
400
- return DriftReport(
401
- labels=dict(sorted(surfaced_labels.items())),
402
- milestones=dict(sorted(surfaced_milestones.items())),
403
- total=len(surfaced_issues),
404
- threshold=effective_threshold,
405
- )
406
-
407
-
408
- def _issue_repo_key(issue: dict[str, Any]) -> str:
409
- """Best-effort repo identifier for a cached issue.
410
-
411
- Tries ``repository_url`` (the canonical REST field), falls back to
412
- ``html_url``, finally to the empty string. Only used to dedupe the
413
- distinct-issue total when an operator caches the same issue number
414
- under two different repos; consumers with a single repo see ``""``
415
- consistently and the dedupe degrades to a per-number set.
416
- """
417
- for key in ("repository_url", "html_url"):
418
- value = issue.get(key)
419
- if isinstance(value, str) and value:
420
- return value
421
- return ""
422
-
423
-
424
- def render_drift_report(report: DriftReport) -> str:
425
- """Render a human-readable view of the report.
426
-
427
- Format (#1133 issue body, lightly adapted)::
428
-
429
- [scope-drift] labels not in subscription:
430
- priority:p0 (12 open issues)
431
- compat:breaking (4 open issues)
432
- [scope-drift] milestones not in subscription:
433
- v2.0-blocker (7 open issues)
434
-
435
- To subscribe:
436
- task triage:subscribe -- --label=priority:p0
437
- task triage:subscribe -- --milestone=v2.0-blocker
438
-
439
- To suppress (record explicit ignore):
440
- task triage:scope-drift -- --ignore-label=priority:p0
441
- task triage:scope-drift -- --ignore-milestone=v2.0-blocker
442
-
443
- Empty reports render a brief "no drift" notice so the operator can
444
- distinguish "ran, none surfaced" from "task failed silently".
445
- """
446
- if report.is_empty():
447
- return (
448
- "[scope-drift] no unsubscribed labels / milestones found "
449
- f"(threshold: >= {report.threshold} cached open issues)."
450
- )
451
-
452
- lines: list[str] = []
453
- if report.labels:
454
- lines.append("[scope-drift] labels not in subscription:")
455
- width = max(len(name) for name in report.labels)
456
- for name, count in report.labels.items():
457
- lines.append(f" {name.ljust(width)} ({count} open issues)")
458
- if report.milestones:
459
- if lines:
460
- lines.append("")
461
- lines.append("[scope-drift] milestones not in subscription:")
462
- width = max(len(name) for name in report.milestones)
463
- for name, count in report.milestones.items():
464
- lines.append(f" {name.ljust(width)} ({count} open issues)")
465
-
466
- lines.append("")
467
- lines.append("To subscribe:")
468
- for name in report.labels:
469
- lines.append(f" task triage:subscribe -- --label={name}")
470
- for name in report.milestones:
471
- lines.append(f" task triage:subscribe -- --milestone={name}")
472
-
473
- lines.append("")
474
- lines.append("To suppress (record explicit ignore):")
475
- for name in report.labels:
476
- lines.append(f" task triage:scope-drift -- --ignore-label={name}")
477
- for name in report.milestones:
478
- lines.append(f" task triage:scope-drift -- --ignore-milestone={name}")
479
-
480
- return "\n".join(lines)
481
-
482
-
483
- def add_ignore(
484
- project_root: Path,
485
- *,
486
- label: str | None = None,
487
- milestone: str | None = None,
488
- ) -> tuple[bool, str]:
489
- """Append a ``{label|milestone: <name>}`` entry to ``plan.policy.triageScopeIgnores[]``.
490
-
491
- Exactly one of ``label`` / ``milestone`` MUST be set. Returns
492
- ``(changed, message)`` -- ``changed`` is False when the entry is
493
- already present (idempotent contract). Writes atomically via
494
- ``os.replace`` so a crash mid-write leaves the file untouched.
495
-
496
- Raises ``ValueError`` when both / neither argument is supplied or
497
- when the value is empty.
498
- """
499
- if (label is None) == (milestone is None):
500
- raise ValueError(
501
- "add_ignore() requires exactly one of label= / milestone="
502
- )
503
- key = "label" if label is not None else "milestone"
504
- value = (label if label is not None else milestone) or ""
505
- if not isinstance(value, str) or not value.strip():
506
- raise ValueError(f"{key} must be a non-empty string; got {value!r}")
507
-
508
- from _project_definition_io import (
509
- atomic_write_project_definition,
510
- load_project_definition_for_mutation,
511
- )
512
-
513
- data, path = load_project_definition_for_mutation(project_root)
514
- plan = data.setdefault("plan", {})
515
- if not isinstance(plan, dict):
516
- raise ValueError(
517
- f"PROJECT-DEFINITION at {path} has a non-object 'plan' key"
518
- )
519
- policy = plan.setdefault("policy", {})
520
- if not isinstance(policy, dict):
521
- raise ValueError(
522
- f"PROJECT-DEFINITION at {path} has a non-object 'plan.policy' key"
523
- )
524
- raw = policy.setdefault("triageScopeIgnores", [])
525
- if not isinstance(raw, list):
526
- raise ValueError(
527
- f"PROJECT-DEFINITION at {path} has a non-list 'plan.policy.triageScopeIgnores'"
528
- )
529
-
530
- before = json.loads(json.dumps(raw))
531
- for entry in raw:
532
- if isinstance(entry, dict) and entry.get(key) == value:
533
- return False, f"already-ignored ({key}={value})"
534
-
535
- raw.append({key: value})
536
- atomic_write_project_definition(path, data)
537
- after = json.loads(json.dumps(raw))
538
- # D14c (#1182): emit an audit entry on every successful mutation so
539
- # the ignore-list surface shares the subscription-history.jsonl
540
- # trail subscribe / unsubscribe write. Failure to import is
541
- # tolerated -- the audit sidecar is observability, not load-bearing.
542
- try:
543
- from triage_subscribe import record_subscription_change
544
-
545
- record_subscription_change(
546
- project_root,
547
- op=f"ignore-{key}",
548
- label=value if key == "label" else None,
549
- milestone=value if key == "milestone" else None,
550
- before=before,
551
- after=after,
552
- )
553
- except Exception: # pragma: no cover -- observability is best-effort
554
- pass
555
- return True, f"added ignore ({key}={value})"
556
-
557
-
558
- def main(argv: list[str] | None = None) -> int:
559
- """CLI entry point. Delegates to :mod:`_triage_scope_drift_cli`."""
560
- import sys as _sys
561
-
562
- # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
563
- from triage_help import intercept_help
564
-
565
- rc = intercept_help("triage_scope_drift", argv)
566
- if rc is not None:
567
- return rc
568
-
569
- from _triage_scope_drift_cli import run_cli # local import: 1000-line cap
570
-
571
- return run_cli(argv, _sys.modules[__name__])
572
-
573
-
574
- if __name__ == "__main__":
575
- sys.exit(main())