@deftai/directive-content 0.55.2 → 0.56.0

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