@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,932 +0,0 @@
1
- #!/usr/bin/env python3
2
- """triage_classify.py -- auto-classification for cached upstream issues (#1129 / D10).
3
-
4
- Wave-1 D10 child of umbrella #1119. Anchored to Current Shape comment
5
- 4471901622 on issue #1129. Ships consumer-agnostic primitives only:
6
-
7
- * :data:`UNIVERSAL_RULES` -- four hardcoded framework rules (Decision 1):
8
- 1. Body contains any hold-marker phrase -> defer ``hold marker in body``.
9
- 2. Closed upstream AND never triaged -> archive ``closed upstream and
10
- never triaged``.
11
- 3. No activity > 90 days AND body absent/<50 chars
12
- -> defer ``dormant; needs AC
13
- refresh``.
14
- 4. Already referenced from pending/active vBRIEFs
15
- -> accept ``already referenced
16
- from a scope vBRIEF``.
17
- * :data:`DEFAULT_HOLD_MARKERS` -- four default hold-marker phrases
18
- (``do not implement`` / ``BLOCKED`` / ``HOLDING`` /
19
- ``Holding / capture only``). Overridable per-consumer via
20
- ``plan.policy.triageHoldMarkers[]`` (Decision 3).
21
- * ``plan.policy.triageAutoClassify[]`` typed-policy schema (Decision 2):
22
-
23
- .. code-block:: json
24
-
25
- {
26
- "match": {
27
- "labels": {"any-of": [...]} | {"all-of": [...]},
28
- "body-text": {"any-of": [...]},
29
- "state": "open" | "closed",
30
- "age-days": {"gt": N}
31
- },
32
- "action": "defer" | "archive" | "escalate" | "accept",
33
- "reason": "<text>",
34
- "resume-on": "<D3 resume condition>" // optional
35
- }
36
-
37
- Framework default for the typed array = **empty** (Decision 2). The four
38
- universal rules above are HARDCODED and consumer-specific label rules
39
- layer on top.
40
-
41
- * Order of evaluation: framework universal rules first, then consumer
42
- rules in declared order; **first match wins** (Decision 2).
43
-
44
- Public API:
45
-
46
- * :func:`validate_classify_rules` / :func:`validate_hold_markers`
47
- * :func:`resolve_classify_rules` / :func:`resolve_hold_markers`
48
- * :func:`classify_issue`
49
- * :func:`validate_triage_auto_classify_on_plan` / :func:`validate_triage_hold_markers_on_plan`
50
- -- vbrief_validate hooks
51
-
52
- §12 boundary: this module ships ZERO deft-specific label / milestone /
53
- state values. Consumer-specific label rules live OUTSIDE the framework
54
- (see #1186 consumer-example child of #1119).
55
- """
56
-
57
- from __future__ import annotations
58
-
59
- import contextlib
60
- import json
61
- import sys
62
- from collections.abc import Iterable
63
- from dataclasses import dataclass
64
- from datetime import UTC, datetime, timedelta
65
- from pathlib import Path
66
- from typing import Any
67
-
68
- # Make sibling scripts importable when invoked as
69
- # ``python scripts/triage_classify.py``.
70
- sys.path.insert(0, str(Path(__file__).resolve().parent))
71
-
72
- # UTF-8 self-reconfigure -- the recap printed by ``--list`` includes the
73
- # checkmark glyphs that cp1252 cannot encode.
74
- for _stream in (sys.stdout, sys.stderr):
75
- if hasattr(_stream, "reconfigure"):
76
- with contextlib.suppress(AttributeError, ValueError):
77
- _stream.reconfigure(encoding="utf-8", errors="replace")
78
-
79
-
80
- # ---------------------------------------------------------------------------
81
- # Public constants
82
- # ---------------------------------------------------------------------------
83
-
84
- #: Filesystem-relative location of the PROJECT-DEFINITION vBRIEF.
85
- PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
86
-
87
- #: Threshold in days for the "dormant" universal rule (Decision 1).
88
- DORMANT_AGE_DAYS: int = 90
89
-
90
- #: Threshold in characters for "thin body" used by the dormant rule.
91
- THIN_BODY_THRESHOLD_CHARS: int = 50
92
-
93
- #: Default hold-marker phrases (Decision 1 + Decision 3). Consumers may
94
- #: extend this list via ``plan.policy.triageHoldMarkers[]``. Note that
95
- #: the matching is case-INsensitive for the all-lowercase / all-uppercase
96
- #: idioms commonly used in issue bodies.
97
- DEFAULT_HOLD_MARKERS: tuple[str, ...] = (
98
- "do not implement",
99
- "BLOCKED",
100
- "HOLDING",
101
- "Holding / capture only",
102
- )
103
-
104
- #: Recognised action values for a consumer rule.
105
- VALID_ACTIONS: frozenset[str] = frozenset({"defer", "archive", "escalate", "accept"})
106
-
107
- #: Recognised state values for the ``match.state`` predicate.
108
- VALID_STATES: frozenset[str] = frozenset({"open", "closed"})
109
-
110
- #: Internal discriminators for the four framework universal rules. These
111
- #: are NOT exposed in the consumer schema; the validator below rejects
112
- #: any consumer rule whose ``match`` block omits the typed predicates.
113
- _UNIVERSAL_RULE_KINDS: tuple[str, ...] = (
114
- "universal:hold-marker",
115
- "universal:closed-never-triaged",
116
- "universal:dormant-thin-body",
117
- "universal:vbrief-referenced",
118
- )
119
-
120
-
121
- @dataclass(frozen=True)
122
- class ClassificationResult:
123
- """Outcome of :func:`classify_issue` when a rule matches.
124
-
125
- ``rule_source`` is ``"framework"`` for the four hardcoded universal
126
- rules and ``"consumer"`` for rules pulled from
127
- ``plan.policy.triageAutoClassify[]``. ``rule_index`` is the 0-based
128
- position within the resolved rule list (universal rules occupy
129
- indices 0..3; consumer rules start at index 4).
130
- """
131
-
132
- action: str
133
- reason: str
134
- rule_index: int
135
- rule_source: str
136
- rule_kind: str
137
- resume_on: str | None = None
138
-
139
-
140
- # ---------------------------------------------------------------------------
141
- # Framework universal rules (Decision 1) -- HARDCODED, consumer-agnostic.
142
- # ---------------------------------------------------------------------------
143
-
144
- #: The four framework universal rules. Encoded as opaque ``rule`` objects
145
- #: so they share the same dispatch surface as consumer rules; the
146
- #: discriminator strings live in :data:`_UNIVERSAL_RULE_KINDS` and are
147
- #: NOT writable from consumer config (the validator rejects any consumer
148
- #: rule whose discriminator starts with ``universal:``).
149
- UNIVERSAL_RULES: tuple[dict[str, Any], ...] = (
150
- {
151
- "rule": "universal:hold-marker",
152
- "action": "defer",
153
- "reason": "hold marker in body",
154
- },
155
- {
156
- "rule": "universal:closed-never-triaged",
157
- "action": "archive",
158
- "reason": "closed upstream and never triaged",
159
- },
160
- {
161
- "rule": "universal:dormant-thin-body",
162
- "action": "defer",
163
- "reason": "dormant; needs AC refresh",
164
- },
165
- {
166
- "rule": "universal:vbrief-referenced",
167
- "action": "accept",
168
- "reason": "already referenced from a scope vBRIEF",
169
- },
170
- )
171
-
172
-
173
- # ---------------------------------------------------------------------------
174
- # Time helpers
175
- # ---------------------------------------------------------------------------
176
-
177
-
178
- def _utc_now() -> datetime:
179
- return datetime.now(UTC)
180
-
181
-
182
- def _parse_iso(stamp: str) -> datetime:
183
- text = stamp.strip()
184
- if text.endswith("Z"):
185
- text = text[:-1] + "+00:00"
186
- return datetime.fromisoformat(text)
187
-
188
-
189
- def _ts_to_dt(value: Any) -> datetime | None:
190
- if not isinstance(value, str) or not value:
191
- return None
192
- try:
193
- dt = _parse_iso(value)
194
- except (ValueError, TypeError):
195
- return None
196
- if dt.tzinfo is None:
197
- dt = dt.replace(tzinfo=UTC)
198
- return dt
199
-
200
-
201
- # ---------------------------------------------------------------------------
202
- # Schema validation -- consumer rules
203
- # ---------------------------------------------------------------------------
204
-
205
-
206
- def validate_classify_rules(rules: Any) -> tuple[list[str], list[str]]:
207
- """Validate a ``plan.policy.triageAutoClassify`` payload.
208
-
209
- Returns ``(errors, warnings)``. ``errors`` is empty on success. The
210
- contract follows the same shape as ``triage_scope.validate_scope_rules``
211
- so call-sites can splice the two error lists together.
212
-
213
- Validation rules (Decision 2):
214
-
215
- * The top-level value MUST be a list (omission is fine and resolves
216
- to an empty consumer rule set via :func:`resolve_classify_rules`).
217
- * Each rule MUST be an object with at minimum ``match``, ``action``,
218
- and ``reason`` keys.
219
- * The ``match`` block MUST contain at least one recognised
220
- predicate (``labels``, ``body-text``, ``state``, ``age-days``);
221
- an empty ``match`` (matches every issue) is rejected as ambiguous.
222
- * Per-predicate field shape is checked (label list, body-text list,
223
- state enum, age-days ``{gt: N}``).
224
- * ``action`` MUST be one of :data:`VALID_ACTIONS`.
225
- * ``reason`` MUST be a non-empty string.
226
- * ``resume-on`` is optional but, when present, MUST be a non-empty
227
- string (a D3 resume-condition expression).
228
- * Any ``rule`` discriminator starting with ``universal:`` is REJECTED
229
- because the framework universal rules are hardcoded and cannot be
230
- re-bound from consumer config.
231
- """
232
- errors: list[str] = []
233
- warnings: list[str] = []
234
-
235
- if rules is None:
236
- return errors, warnings
237
-
238
- if not isinstance(rules, list):
239
- errors.append(
240
- "plan.policy.triageAutoClassify must be a list of rule objects; "
241
- f"got {type(rules).__name__}"
242
- )
243
- return errors, warnings
244
-
245
- for i, rule in enumerate(rules):
246
- prefix = f"plan.policy.triageAutoClassify[{i}]"
247
- if not isinstance(rule, dict):
248
- errors.append(f"{prefix} must be an object, got {type(rule).__name__}")
249
- continue
250
- _validate_consumer_rule(rule, prefix, errors, warnings)
251
-
252
- return errors, warnings
253
-
254
-
255
- def _validate_consumer_rule(
256
- rule: dict[str, Any], prefix: str, errors: list[str], warnings: list[str]
257
- ) -> None:
258
- # Reject re-binding the universal discriminators.
259
- kind = rule.get("rule")
260
- if isinstance(kind, str) and kind.startswith("universal:"):
261
- errors.append(
262
- f"{prefix}.rule {kind!r} is reserved for framework universal "
263
- "rules (#1129 Decision 1); consumer rules MUST omit the "
264
- "'rule' field or use a non-'universal:' discriminator"
265
- )
266
- return
267
-
268
- # action
269
- action = rule.get("action")
270
- if not isinstance(action, str) or action not in VALID_ACTIONS:
271
- errors.append(
272
- f"{prefix}.action must be one of {sorted(VALID_ACTIONS)}; "
273
- f"got {action!r}"
274
- )
275
-
276
- # reason
277
- reason = rule.get("reason")
278
- if not isinstance(reason, str) or not reason.strip():
279
- errors.append(f"{prefix}.reason must be a non-empty string")
280
-
281
- # resume-on (optional)
282
- if "resume-on" in rule:
283
- ro = rule["resume-on"]
284
- if not isinstance(ro, str) or not ro.strip():
285
- errors.append(f"{prefix}.resume-on must be a non-empty string when set")
286
-
287
- # match block
288
- match = rule.get("match")
289
- if not isinstance(match, dict):
290
- errors.append(f"{prefix}.match must be an object")
291
- return
292
-
293
- recognised_predicates = {"labels", "body-text", "state", "age-days"}
294
- extra = sorted(set(match) - recognised_predicates)
295
- if extra:
296
- warnings.append(
297
- f"{prefix}.match: ignoring unrecognised predicate(s) {extra}; "
298
- f"expected one or more of {sorted(recognised_predicates)}"
299
- )
300
- used_predicates = sorted(set(match) & recognised_predicates)
301
- if not used_predicates:
302
- errors.append(
303
- f"{prefix}.match requires at least one of "
304
- f"{sorted(recognised_predicates)}"
305
- )
306
- return
307
-
308
- if "labels" in match:
309
- _validate_labels_predicate(match["labels"], f"{prefix}.match.labels", errors)
310
- if "body-text" in match:
311
- _validate_body_text_predicate(
312
- match["body-text"], f"{prefix}.match.body-text", errors
313
- )
314
- if "state" in match:
315
- state = match["state"]
316
- if state not in VALID_STATES:
317
- errors.append(
318
- f"{prefix}.match.state must be one of {sorted(VALID_STATES)}; "
319
- f"got {state!r}"
320
- )
321
- if "age-days" in match:
322
- _validate_age_days_predicate(
323
- match["age-days"], f"{prefix}.match.age-days", errors
324
- )
325
-
326
-
327
- def _validate_labels_predicate(value: Any, prefix: str, errors: list[str]) -> None:
328
- if not isinstance(value, dict):
329
- errors.append(f"{prefix} must be an object")
330
- return
331
- any_of = value.get("any-of")
332
- all_of = value.get("all-of")
333
- if any_of is None and all_of is None:
334
- errors.append(f"{prefix} requires 'any-of' or 'all-of'")
335
- return
336
- if any_of is not None and all_of is not None:
337
- errors.append(f"{prefix}: 'any-of' and 'all-of' are mutually exclusive")
338
- return
339
- target = any_of if any_of is not None else all_of
340
- which = "any-of" if any_of is not None else "all-of"
341
- if not isinstance(target, list) or not target:
342
- errors.append(f"{prefix}.{which} must be a non-empty list of strings")
343
- return
344
- for j, label in enumerate(target):
345
- if not isinstance(label, str) or not label:
346
- errors.append(f"{prefix}.{which}[{j}] must be a non-empty string")
347
-
348
-
349
- def _validate_body_text_predicate(value: Any, prefix: str, errors: list[str]) -> None:
350
- if not isinstance(value, dict):
351
- errors.append(f"{prefix} must be an object")
352
- return
353
- any_of = value.get("any-of")
354
- if not isinstance(any_of, list) or not any_of:
355
- errors.append(f"{prefix}.any-of must be a non-empty list of strings")
356
- return
357
- for j, needle in enumerate(any_of):
358
- if not isinstance(needle, str) or not needle:
359
- errors.append(f"{prefix}.any-of[{j}] must be a non-empty string")
360
-
361
-
362
- def _validate_age_days_predicate(value: Any, prefix: str, errors: list[str]) -> None:
363
- if not isinstance(value, dict):
364
- errors.append(f"{prefix} must be an object")
365
- return
366
- if "gt" not in value:
367
- errors.append(f"{prefix} requires a 'gt' integer threshold")
368
- return
369
- gt = value["gt"]
370
- if (
371
- not isinstance(gt, int)
372
- or isinstance(gt, bool)
373
- or gt < 0
374
- ):
375
- errors.append(f"{prefix}.gt must be a non-negative integer; got {gt!r}")
376
-
377
-
378
- # ---------------------------------------------------------------------------
379
- # Schema validation -- hold markers
380
- # ---------------------------------------------------------------------------
381
-
382
-
383
- def validate_hold_markers(markers: Any) -> tuple[list[str], list[str]]:
384
- """Validate a ``plan.policy.triageHoldMarkers`` payload.
385
-
386
- Returns ``(errors, warnings)``. An unset / missing list resolves to
387
- :data:`DEFAULT_HOLD_MARKERS` (Decision 3). An EMPTY list is accepted
388
- and silences the hold-marker universal rule entirely (operators who
389
- want zero hold-marker matching can set ``triageHoldMarkers: []``).
390
- """
391
- errors: list[str] = []
392
- warnings: list[str] = []
393
- if markers is None:
394
- return errors, warnings
395
- if not isinstance(markers, list):
396
- errors.append(
397
- "plan.policy.triageHoldMarkers must be a list of strings; "
398
- f"got {type(markers).__name__}"
399
- )
400
- return errors, warnings
401
- for i, marker in enumerate(markers):
402
- if not isinstance(marker, str) or not marker.strip():
403
- errors.append(
404
- f"plan.policy.triageHoldMarkers[{i}] must be a non-empty string"
405
- )
406
- return errors, warnings
407
-
408
-
409
- # ---------------------------------------------------------------------------
410
- # Resolve rules + hold markers from PROJECT-DEFINITION
411
- # ---------------------------------------------------------------------------
412
-
413
-
414
- def project_definition_path(project_root: Path | None = None) -> Path:
415
- root = project_root or Path.cwd()
416
- return root / PROJECT_DEFINITION_REL_PATH
417
-
418
-
419
- def _load_project_definition(project_root: Path | None = None) -> dict[str, Any] | None:
420
- path = project_definition_path(project_root)
421
- if not path.is_file():
422
- return None
423
- try:
424
- data = json.loads(path.read_text(encoding="utf-8"))
425
- except (json.JSONDecodeError, OSError):
426
- return None
427
- return data if isinstance(data, dict) else None
428
-
429
-
430
- def _consumer_rules_from_project(
431
- data: dict[str, Any] | None,
432
- ) -> list[dict[str, Any]]:
433
- if not isinstance(data, dict):
434
- return []
435
- plan = data.get("plan")
436
- if not isinstance(plan, dict):
437
- return []
438
- policy = plan.get("policy")
439
- if not isinstance(policy, dict):
440
- return []
441
- raw = policy.get("triageAutoClassify")
442
- if not isinstance(raw, list):
443
- return []
444
- return [dict(r) for r in raw if isinstance(r, dict)]
445
-
446
-
447
- def _hold_markers_from_project(
448
- data: dict[str, Any] | None,
449
- ) -> list[str] | None:
450
- """Return the raw hold-marker list from PROJECT-DEFINITION, or None
451
- when unset / non-list. ``None`` means "use defaults"; an EMPTY list
452
- means "silence the hold-marker rule" (Decision 3 explicit opt-out).
453
- """
454
- if not isinstance(data, dict):
455
- return None
456
- plan = data.get("plan")
457
- if not isinstance(plan, dict):
458
- return None
459
- policy = plan.get("policy")
460
- if not isinstance(policy, dict):
461
- return None
462
- raw = policy.get("triageHoldMarkers")
463
- if not isinstance(raw, list):
464
- return None
465
- return [m for m in raw if isinstance(m, str) and m.strip()]
466
-
467
-
468
- def resolve_classify_rules(
469
- project_root: Path | None = None,
470
- *,
471
- project_definition: dict[str, Any] | None = None,
472
- ) -> list[dict[str, Any]]:
473
- """Return ``UNIVERSAL_RULES`` followed by the consumer rules.
474
-
475
- Order of evaluation (Decision 2): framework universal rules first,
476
- then consumer rules in declared order. The returned list is a fresh
477
- shallow copy so callers can mutate it without disturbing the
478
- framework constants.
479
- """
480
- data = (
481
- project_definition
482
- if project_definition is not None
483
- else _load_project_definition(project_root)
484
- )
485
- consumer = _consumer_rules_from_project(data)
486
- return [dict(r) for r in UNIVERSAL_RULES] + consumer
487
-
488
-
489
- def resolve_hold_markers(
490
- project_root: Path | None = None,
491
- *,
492
- project_definition: dict[str, Any] | None = None,
493
- ) -> list[str]:
494
- """Return the effective hold-marker list (defaults + consumer override)."""
495
- data = (
496
- project_definition
497
- if project_definition is not None
498
- else _load_project_definition(project_root)
499
- )
500
- raw = _hold_markers_from_project(data)
501
- if raw is None:
502
- return list(DEFAULT_HOLD_MARKERS)
503
- return list(raw)
504
-
505
-
506
- # ---------------------------------------------------------------------------
507
- # Issue field accessors
508
- # ---------------------------------------------------------------------------
509
-
510
-
511
- def _issue_number(issue: dict[str, Any]) -> int:
512
- n = issue.get("number")
513
- return int(n) if isinstance(n, int) and not isinstance(n, bool) else 0
514
-
515
-
516
- def _issue_state(issue: dict[str, Any]) -> str:
517
- state = issue.get("state", "open")
518
- return state if isinstance(state, str) else "open"
519
-
520
-
521
- def _issue_body(issue: dict[str, Any]) -> str:
522
- body = issue.get("body")
523
- if isinstance(body, str):
524
- return body
525
- return ""
526
-
527
-
528
- def _issue_label_names(issue: dict[str, Any]) -> set[str]:
529
- raw = issue.get("labels", [])
530
- names: set[str] = set()
531
- if not isinstance(raw, list):
532
- return names
533
- for item in raw:
534
- if isinstance(item, dict):
535
- name = item.get("name")
536
- if isinstance(name, str):
537
- names.add(name)
538
- elif isinstance(item, str):
539
- names.add(item)
540
- return names
541
-
542
-
543
- def _issue_updated_at(issue: dict[str, Any]) -> datetime | None:
544
- return _ts_to_dt(issue.get("updated_at"))
545
-
546
-
547
- def _issue_created_at(issue: dict[str, Any]) -> datetime | None:
548
- return _ts_to_dt(issue.get("created_at"))
549
-
550
-
551
- # ---------------------------------------------------------------------------
552
- # Universal rule predicates
553
- # ---------------------------------------------------------------------------
554
-
555
-
556
- def _matches_hold_marker(
557
- issue: dict[str, Any], hold_markers: Iterable[str]
558
- ) -> bool:
559
- """True when the issue body contains any hold-marker phrase.
560
-
561
- Matching is case-INsensitive so an issue body that writes
562
- ``do not implement`` in any casing trips the rule. The default
563
- markers include both an all-caps idiom (``BLOCKED``) and a sentence-
564
- cased phrase (``Holding / capture only``) so consumers writing
565
- in their natural style are still caught.
566
- """
567
- body = _issue_body(issue)
568
- if not body:
569
- return False
570
- haystack = body.casefold()
571
- for marker in hold_markers:
572
- if not marker:
573
- continue
574
- if marker.casefold() in haystack:
575
- return True
576
- return False
577
-
578
-
579
- def _matches_closed_never_triaged(
580
- issue: dict[str, Any], *, has_triage_decision: bool
581
- ) -> bool:
582
- return _issue_state(issue) == "closed" and not has_triage_decision
583
-
584
-
585
- def _matches_dormant_thin_body(
586
- issue: dict[str, Any], *, now: datetime, age_days: int = DORMANT_AGE_DAYS
587
- ) -> bool:
588
- if _issue_state(issue) != "open":
589
- return False
590
- updated = _issue_updated_at(issue) or _issue_created_at(issue)
591
- if updated is None:
592
- return False
593
- if (now - updated) <= timedelta(days=age_days):
594
- return False
595
- body = _issue_body(issue).strip()
596
- return len(body) < THIN_BODY_THRESHOLD_CHARS
597
-
598
-
599
- def _matches_vbrief_referenced(
600
- issue: dict[str, Any], *, vbrief_referenced: set[int] | None
601
- ) -> bool:
602
- if not vbrief_referenced:
603
- return False
604
- return _issue_number(issue) in vbrief_referenced
605
-
606
-
607
- # ---------------------------------------------------------------------------
608
- # Consumer rule predicate
609
- # ---------------------------------------------------------------------------
610
-
611
-
612
- def _consumer_rule_matches(
613
- rule: dict[str, Any], issue: dict[str, Any], *, now: datetime
614
- ) -> bool:
615
- match = rule.get("match")
616
- if not isinstance(match, dict):
617
- return False
618
-
619
- if "state" in match:
620
- wanted = match["state"]
621
- if _issue_state(issue) != wanted:
622
- return False
623
-
624
- if "labels" in match:
625
- labels_pred = match["labels"]
626
- names = _issue_label_names(issue)
627
- any_of = labels_pred.get("any-of") if isinstance(labels_pred, dict) else None
628
- all_of = labels_pred.get("all-of") if isinstance(labels_pred, dict) else None
629
- if any_of is not None:
630
- if not any(label in names for label in any_of):
631
- return False
632
- elif all_of is not None:
633
- if not all(label in names for label in all_of):
634
- return False
635
- else:
636
- return False
637
-
638
- if "body-text" in match:
639
- body_pred = match["body-text"]
640
- any_of = body_pred.get("any-of") if isinstance(body_pred, dict) else None
641
- if not isinstance(any_of, list) or not any_of:
642
- return False
643
- body = _issue_body(issue).casefold()
644
- if not any(
645
- isinstance(n, str) and n and n.casefold() in body for n in any_of
646
- ):
647
- return False
648
-
649
- if "age-days" in match:
650
- pred = match["age-days"]
651
- gt = pred.get("gt") if isinstance(pred, dict) else None
652
- if not isinstance(gt, int) or isinstance(gt, bool):
653
- return False
654
- updated = _issue_updated_at(issue) or _issue_created_at(issue)
655
- if updated is None:
656
- return False
657
- if (now - updated) <= timedelta(days=gt):
658
- return False
659
-
660
- return True
661
-
662
-
663
- # ---------------------------------------------------------------------------
664
- # classify_issue
665
- # ---------------------------------------------------------------------------
666
-
667
-
668
- def classify_issue(
669
- issue: dict[str, Any],
670
- *,
671
- rules: list[dict[str, Any]] | None = None,
672
- hold_markers: list[str] | None = None,
673
- vbrief_referenced: set[int] | None = None,
674
- has_triage_decision: bool = False,
675
- now: datetime | None = None,
676
- ) -> ClassificationResult | None:
677
- """Classify a single issue against the effective rule set.
678
-
679
- Order of evaluation (Decision 2): framework universal rules first,
680
- then consumer rules in declared order; the FIRST rule that matches
681
- wins and the function returns its action / reason.
682
-
683
- Arguments:
684
-
685
- * ``issue`` -- a GitHub-issue-shaped dict (at minimum: ``number``,
686
- ``state``, ``body``, ``labels``, ``updated_at``).
687
- * ``rules`` -- the rule list returned by :func:`resolve_classify_rules`.
688
- Defaults to ``UNIVERSAL_RULES`` with no consumer additions.
689
- * ``hold_markers`` -- the hold-marker phrases consumed by the first
690
- universal rule. Defaults to :data:`DEFAULT_HOLD_MARKERS`. Pass an
691
- empty list to silence the hold-marker rule entirely.
692
- * ``vbrief_referenced`` -- issue numbers referenced by any pending/active
693
- scope vBRIEF. Drives the fourth universal rule.
694
- * ``has_triage_decision`` -- True iff the candidates audit log has
695
- ANY decision for this ``(repo, issue)``. Drives the second
696
- universal rule (closed AND never triaged -> archive).
697
- * ``now`` -- evaluation clock; defaults to UTC now. Tests override.
698
-
699
- Returns ``None`` when no rule matches (the issue is left for the
700
- operator / queue ranking to handle in the next phase).
701
- """
702
- if rules is None:
703
- rules = [dict(r) for r in UNIVERSAL_RULES]
704
- effective_markers = (
705
- list(DEFAULT_HOLD_MARKERS) if hold_markers is None else list(hold_markers)
706
- )
707
- now_dt = now or _utc_now()
708
-
709
- for index, rule in enumerate(rules):
710
- kind = rule.get("rule") if isinstance(rule, dict) else None
711
- matched = False
712
- if kind == "universal:hold-marker":
713
- matched = _matches_hold_marker(issue, effective_markers)
714
- elif kind == "universal:closed-never-triaged":
715
- matched = _matches_closed_never_triaged(
716
- issue, has_triage_decision=has_triage_decision
717
- )
718
- elif kind == "universal:dormant-thin-body":
719
- matched = _matches_dormant_thin_body(issue, now=now_dt)
720
- elif kind == "universal:vbrief-referenced":
721
- matched = _matches_vbrief_referenced(
722
- issue, vbrief_referenced=vbrief_referenced
723
- )
724
- elif isinstance(rule, dict):
725
- matched = _consumer_rule_matches(rule, issue, now=now_dt)
726
-
727
- if not matched:
728
- continue
729
-
730
- source = "framework" if isinstance(kind, str) and kind.startswith(
731
- "universal:"
732
- ) else "consumer"
733
- return ClassificationResult(
734
- action=str(rule.get("action", "")),
735
- reason=str(rule.get("reason", "")),
736
- rule_index=index,
737
- rule_source=source,
738
- rule_kind=str(kind) if isinstance(kind, str) else f"consumer[{index}]",
739
- resume_on=(
740
- str(rule["resume-on"])
741
- if isinstance(rule.get("resume-on"), str) and rule["resume-on"]
742
- else None
743
- ),
744
- )
745
-
746
- return None
747
-
748
-
749
- # ---------------------------------------------------------------------------
750
- # vBRIEF reference helper (mirror of triage_scope.extract_referenced_issues)
751
- # ---------------------------------------------------------------------------
752
-
753
-
754
- def extract_referenced_issues(
755
- project_root: Path | None = None,
756
- *,
757
- lifecycle_folders: tuple[str, ...] = ("pending", "active"),
758
- ) -> set[int]:
759
- """Return the union of issue numbers referenced by ``pending/`` or
760
- ``active/`` scope vBRIEFs.
761
-
762
- Used to drive the fourth universal rule (already referenced -> accept).
763
- Limited to pending/active by default because completed/cancelled
764
- references shouldn't reactivate the upstream issue. The
765
- ``lifecycle_folders`` knob lets callers override for the rare cases
766
- (e.g. cohort planning) where they want broader coverage.
767
- """
768
- root = (project_root or Path.cwd()) / "vbrief"
769
- referenced: set[int] = set()
770
- if not root.is_dir():
771
- return referenced
772
- for folder in lifecycle_folders:
773
- folder_path = root / folder
774
- if not folder_path.is_dir():
775
- continue
776
- for vbrief_path in folder_path.glob("*.vbrief.json"):
777
- try:
778
- data = json.loads(vbrief_path.read_text(encoding="utf-8"))
779
- except (json.JSONDecodeError, OSError):
780
- continue
781
- plan = data.get("plan") if isinstance(data, dict) else None
782
- if not isinstance(plan, dict):
783
- continue
784
- refs = plan.get("references") or []
785
- if not isinstance(refs, list):
786
- continue
787
- for ref in refs:
788
- if not isinstance(ref, dict):
789
- continue
790
- if ref.get("type") != "x-vbrief/github-issue":
791
- continue
792
- uri = ref.get("uri", "")
793
- if not isinstance(uri, str):
794
- continue
795
- tail = uri.rstrip("/").rsplit("/", 1)[-1]
796
- if tail.isdigit():
797
- referenced.add(int(tail))
798
- return referenced
799
-
800
-
801
- # ---------------------------------------------------------------------------
802
- # --list renderer
803
- # ---------------------------------------------------------------------------
804
-
805
-
806
- def render_list(
807
- rules: Iterable[dict[str, Any]],
808
- *,
809
- hold_markers: Iterable[str] | None = None,
810
- ) -> str:
811
- """Return a human-readable recap of the effective rule + marker set.
812
-
813
- Format::
814
-
815
- triage:classify effective rules (N) (framework + consumer):
816
- 1. universal:hold-marker -> defer (hold marker in body)
817
- 2. universal:closed-never-triaged -> archive (closed upstream...)
818
- ...
819
- 5. consumer rule [action=defer, labels.any-of=['foo']]
820
- hold markers (M): ['do not implement', 'BLOCKED', ...]
821
- """
822
- rule_list = list(rules)
823
- marker_list = (
824
- list(DEFAULT_HOLD_MARKERS) if hold_markers is None else list(hold_markers)
825
- )
826
- lines: list[str] = [
827
- f"triage:classify effective rules ({len(rule_list)}) "
828
- "(framework universal first, then consumer):"
829
- ]
830
- for i, rule in enumerate(rule_list, start=1):
831
- lines.extend(_render_rule(i, rule))
832
- lines.append(f"hold markers ({len(marker_list)}): {marker_list}")
833
- return "\n".join(lines)
834
-
835
-
836
- def _render_rule(idx: int, rule: dict[str, Any]) -> list[str]:
837
- kind = rule.get("rule")
838
- action = rule.get("action", "?")
839
- reason = rule.get("reason", "")
840
- if isinstance(kind, str) and kind.startswith("universal:"):
841
- return [f" {idx}. {kind:32s} -> {action:8s} ({reason})"]
842
- match = rule.get("match", {})
843
- parts: list[str] = []
844
- if isinstance(match, dict):
845
- if "labels" in match:
846
- labels = match["labels"]
847
- if isinstance(labels, dict):
848
- if "any-of" in labels:
849
- parts.append(f"labels.any-of={sorted(labels['any-of'])}")
850
- elif "all-of" in labels:
851
- parts.append(f"labels.all-of={sorted(labels['all-of'])}")
852
- if "body-text" in match:
853
- body = match["body-text"]
854
- if isinstance(body, dict) and "any-of" in body:
855
- parts.append(f"body-text.any-of={sorted(body['any-of'])}")
856
- if "state" in match:
857
- parts.append(f"state={match['state']!r}")
858
- if "age-days" in match:
859
- age = match["age-days"]
860
- if isinstance(age, dict) and "gt" in age:
861
- parts.append(f"age-days.gt={age['gt']}")
862
- head = (
863
- f" {idx}. consumer rule "
864
- f"-> {action:8s} ({reason})"
865
- )
866
- if parts:
867
- head = f"{head} :: {', '.join(parts)}"
868
- if isinstance(rule.get("resume-on"), str) and rule["resume-on"]:
869
- head = f"{head} [resume-on: {rule['resume-on']}]"
870
- return [head]
871
-
872
-
873
- # ---------------------------------------------------------------------------
874
- # vbrief_validate hooks
875
- # ---------------------------------------------------------------------------
876
-
877
-
878
- def validate_triage_auto_classify_on_plan(plan: Any, filepath: Any) -> list[str]:
879
- """vbrief_validate hook for ``plan.policy.triageAutoClassify`` (#1129).
880
-
881
- Returns formatted error strings prefixed with ``<filepath>:`` so
882
- ``vbrief_validate.validate_project_definition`` can splice them into
883
- its existing error list. Unset / missing -> no errors (default
884
- behaviour per Decision 2).
885
- """
886
- out: list[str] = []
887
- policy = plan.get("policy") if isinstance(plan, dict) else None
888
- raw = policy.get("triageAutoClassify") if isinstance(policy, dict) else None
889
- if raw is None:
890
- return out
891
- errors, _warnings = validate_classify_rules(raw)
892
- for err in errors:
893
- out.append(f"{filepath}: {err} (#1129)")
894
- return out
895
-
896
-
897
- def validate_triage_hold_markers_on_plan(plan: Any, filepath: Any) -> list[str]:
898
- """vbrief_validate hook for ``plan.policy.triageHoldMarkers`` (#1129)."""
899
- out: list[str] = []
900
- policy = plan.get("policy") if isinstance(plan, dict) else None
901
- raw = policy.get("triageHoldMarkers") if isinstance(policy, dict) else None
902
- if raw is None:
903
- return out
904
- errors, _warnings = validate_hold_markers(raw)
905
- for err in errors:
906
- out.append(f"{filepath}: {err} (#1129)")
907
- return out
908
-
909
-
910
- # ---------------------------------------------------------------------------
911
- # CLI entry point (delegates to scripts/_triage_classify_cli.py)
912
- # ---------------------------------------------------------------------------
913
-
914
-
915
- def main(argv: list[str] | None = None) -> int:
916
- """CLI entry point. Delegates to :mod:`_triage_classify_cli`."""
917
- import sys as _sys
918
-
919
- # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
920
- from triage_help import intercept_help
921
-
922
- rc = intercept_help("triage_classify", argv)
923
- if rc is not None:
924
- return rc
925
-
926
- from _triage_classify_cli import run_cli # local import: 1000-line cap
927
-
928
- return run_cli(argv, _sys.modules[__name__])
929
-
930
-
931
- if __name__ == "__main__":
932
- sys.exit(main())