@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,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())