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