@deftai/directive-content 0.55.1 → 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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,1944 @@
1
+ #!/usr/bin/env python3
2
+ """triage_queue.py -- ranked triage queue + per-item show + audit surface (#1128 / D11).
3
+
4
+ Wave-1 D11 ships three read-only triage surfaces against the unified
5
+ cache layer (#883 Story 2) and the append-only audit log (#845 Story 2):
6
+
7
+ * ``task triage:queue [--limit N]`` -- hybrid ranked work selection.
8
+ Groups (display order): ``[RESUME]`` -> ``[URGENT]`` -> untriaged
9
+ -> other. Within-group framework default = ``updated_at`` descending;
10
+ consumer-supplied ``plan.policy.triageRankingLabels[]`` (typed; framework
11
+ default empty per umbrella section 12 framework-vs-consumer boundary)
12
+ re-orders within-group by matched-label declared order, then
13
+ ``updated_at`` desc.
14
+ * ``task triage:show <N>`` -- per-item read-only detail (cached
15
+ upstream payload + latest triage decision + audit timeline).
16
+ * ``task triage:audit [--format=json] [--vbrief-staleness]`` -- audit-log
17
+ surface used by D2 (#1122) for triage:summary integration and by D4
18
+ (#1124) for cap-reached error message integration.
19
+
20
+ The framework default for ``--explain <N>`` and weighted multi-signal
21
+ ranking are explicitly DEFERRED to follow-up children per the
22
+ Current Shape v2 amendment (comment 4471272093 on #1128).
23
+
24
+ Per ``conventions/task-caching.md`` the Taskfile fragment must NOT cache
25
+ the ``cmds:`` block: every subcommand accepts user-facing flags via
26
+ ``{{.CLI_ARGS}}``.
27
+
28
+ Programmatic API
29
+ ----------------
30
+
31
+ * :func:`resolve_ranking_labels` -- read effective ``plan.policy.triageRankingLabels[]``
32
+ (default: ``[]``).
33
+ * :func:`validate_ranking_labels` -- structural validation of the typed
34
+ value. Returns ``(errors, warnings)``.
35
+ * :func:`validate_triage_ranking_labels_on_plan` -- ``vbrief_validate``
36
+ hook used from :mod:`vbrief_validate`.
37
+ * :func:`derive_group` -- map ``(latest_decision, in_active_vbrief)`` to
38
+ one of ``"RESUME" | "URGENT" | "untriaged" | "other"``.
39
+ * :func:`load_cached_issues` -- walk
40
+ ``.deft-cache/github-issue/<owner>/<repo>/<N>/raw.json`` and yield the
41
+ cached issue payloads. Closed issues are excluded by default.
42
+ * :func:`build_queue` -- compose the grouped + within-group-ranked queue.
43
+ * :func:`render_queue` / :func:`render_show` / :func:`render_audit` --
44
+ pure text renderers consumed by the CLI shim below.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import contextlib
50
+ import json
51
+ import os
52
+ import subprocess
53
+ import sys
54
+ from collections.abc import Callable, Iterable, Mapping
55
+ from dataclasses import dataclass, field
56
+ from datetime import UTC, datetime, timedelta
57
+ from pathlib import Path
58
+ from typing import Any
59
+
60
+ # Make sibling scripts importable when invoked as ``python scripts/triage_queue.py``.
61
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
62
+
63
+ # UTF-8 self-reconfigure -- the queue renderer prints group markers and
64
+ # arrow glyphs that cp1252 cannot encode (#814).
65
+ for _stream in (sys.stdout, sys.stderr):
66
+ if hasattr(_stream, "reconfigure"):
67
+ with contextlib.suppress(AttributeError, ValueError):
68
+ _stream.reconfigure(encoding="utf-8", errors="replace")
69
+
70
+ # Public, frozen interfaces -- guarded so this module imports cleanly on
71
+ # checkouts that have not yet rebased onto the upstream PRs.
72
+ try: # pragma: no cover -- exercised once #845 Story 2 lands.
73
+ import candidates_log # type: ignore[import-not-found]
74
+ except ImportError: # pragma: no cover
75
+ candidates_log = None # type: ignore[assignment]
76
+
77
+ try: # pragma: no cover -- exercised once D12 (#1131) lands.
78
+ import triage_scope # type: ignore[import-not-found]
79
+ except ImportError: # pragma: no cover
80
+ triage_scope = None # type: ignore[assignment]
81
+
82
+ # Optional dep: resume-condition evaluator (#1123 / D3). When importable,
83
+ # ``task triage:audit --evaluate-resume`` invokes the evaluator before
84
+ # rendering the audit dump so any fired ``resume_on`` conditions surface
85
+ # in the same call.
86
+ try: # pragma: no cover -- exercised once #1123 lands.
87
+ import resume_conditions # type: ignore[import-not-found]
88
+ except ImportError: # pragma: no cover
89
+ resume_conditions = None # type: ignore[assignment]
90
+
91
+ # Optional dep: slice-cohort writer (#1132 / D13). When importable, the
92
+ # slice operation flags on the ``audit`` subcommand (``--orphans``,
93
+ # ``--slice-stalled``, ``--slice-coverage``) read ``vbrief/.eval/slices.jsonl``
94
+ # via this module. Slim test checkouts that have not yet rebased onto D13
95
+ # get a no-op fallback (empty result + informational stderr).
96
+ try: # pragma: no cover -- exercised once #1132 lands.
97
+ import slice_record # type: ignore[import-not-found]
98
+ except ImportError: # pragma: no cover
99
+ slice_record = None # type: ignore[assignment]
100
+
101
+ # Optional dep: cache-freshness predicate (#1127 / #1476). Supplies the
102
+ # shared ``is_fetched_at_stale`` window used by the defensive stale-state
103
+ # re-resolution in :func:`load_cached_issues`. When absent the defensive
104
+ # path is disabled (entries are treated as fresh) so the queue still
105
+ # walks the cache on a partial / pre-#1127 checkout.
106
+ try: # pragma: no cover -- preflight_cache is a sibling in this repo.
107
+ import preflight_cache # type: ignore[import-not-found]
108
+ except ImportError: # pragma: no cover
109
+ preflight_cache = None # type: ignore[assignment]
110
+
111
+ # Spec-readiness contract (#1419 Slice 1 / #987). Reuse the shared
112
+ # swarm-readiness / story-quality checks rather than inventing a parallel
113
+ # field set. Guarded for slim checkouts so the queue still imports if the
114
+ # helper is absent (the predicate then degrades to the readiness gate).
115
+ try: # pragma: no cover -- core sibling in this repo.
116
+ import _vbrief_story_quality # type: ignore[import-not-found]
117
+ except ImportError: # pragma: no cover
118
+ _vbrief_story_quality = None # type: ignore[assignment]
119
+
120
+ # Capacity-allocation accounting (#1419 Slice 4). Slice 2 (#987) READS the
121
+ # Slice-4 per-bucket deficit tallies to bias net-new selection toward the
122
+ # most-under-target bucket. IMPORT-ONLY -- this module never edits the
123
+ # capacity engine. Guarded so the queue still imports on a slim checkout.
124
+ try: # pragma: no cover -- core sibling in this repo.
125
+ import capacity_show # type: ignore[import-not-found]
126
+ except ImportError: # pragma: no cover
127
+ capacity_show = None # type: ignore[assignment]
128
+
129
+ # Typed-policy surface (#746 / #1124 / #1419). Slice 2 reads ``wipCap`` and
130
+ # ``capacityAllocation`` for the optional ``finishBeforeStart`` eligibility
131
+ # policy. Guarded for slim checkouts (the finishBeforeStart gate then stays
132
+ # inert rather than raising).
133
+ try: # pragma: no cover -- core sibling in this repo.
134
+ import policy as _policy # type: ignore[import-not-found]
135
+ except ImportError: # pragma: no cover
136
+ _policy = None # type: ignore[assignment]
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Public constants
141
+ # ---------------------------------------------------------------------------
142
+
143
+ #: Filesystem-relative location of the unified cache root (#883 Story 2).
144
+ CACHE_DIR_NAME = ".deft-cache"
145
+
146
+ #: Cache source layer for upstream GitHub issues. v1 ships github-issue only.
147
+ CACHE_SOURCE_GITHUB_ISSUE = "github-issue"
148
+
149
+ #: Env var honoured for repo inference when ``--repo`` is absent (#1238).
150
+ #: Mirrors ``scripts/preflight_cache.py::ENV_TRIAGE_REPO`` and
151
+ #: ``scripts/triage_bootstrap.py`` so every triage verb shares one
152
+ #: resolution chain (flag > env > git origin).
153
+ ENV_TRIAGE_REPO = "DEFT_TRIAGE_REPO"
154
+
155
+ #: PROJECT-DEFINITION vBRIEF location for typed-policy lookup.
156
+ PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
157
+
158
+ #: Default queue limit when ``--limit`` is omitted on the CLI surface.
159
+ DEFAULT_QUEUE_LIMIT: int = 25
160
+
161
+ #: Default stalled-cohort window in days for ``task triage:audit --slice-stalled``
162
+ #: (#1132 / D13). Selectable per-invocation via ``--days N``. 30 days matches
163
+ #: the issue body's example fixture and the umbrella amendment timeframe.
164
+ DEFAULT_SLICE_STALLED_DAYS: int = 30
165
+
166
+ #: Group display order. Mirrors Current Shape v2 Decision 1 plus the D13
167
+ #: (#1132) ``ORPHAN`` insertion ABOVE ``RESUME``. The strings themselves
168
+ #: are also the user-visible markers in :func:`render_queue`. ``ORPHAN``
169
+ #: sits above ``RESUME`` because the orphan signal indicates work the
170
+ #: framework already committed to and risks losing (issue #1132 spec:
171
+ #: ``+8`` rank > resume-eligible ``+5``, below ``breaking-change`` ``+10``).
172
+ #: Within-group ranking labels (e.g. ``breaking-change``) still apply, so
173
+ #: a ``breaking-change``-labelled orphan tops the queue while a plain
174
+ #: orphan still sits above a resume-eligible item.
175
+ #:
176
+ #: ``BLOCKED`` sits at the BOTTOM (#1286): an item whose linked vBRIEF has
177
+ #: ``plan.status == "blocked"`` or an unresolved
178
+ #: ``plan.metadata.swarm.depends_on`` is demoted there by default so the
179
+ #: ranked list surfaces only grabbable work. The ``--include-blocked``
180
+ #: opt-in re-surfaces such items into their natural group instead.
181
+ GROUP_ORDER: tuple[str, ...] = (
182
+ "ORPHAN",
183
+ "RESUME",
184
+ "URGENT",
185
+ "untriaged",
186
+ "other",
187
+ "BLOCKED",
188
+ )
189
+
190
+ #: Display labels per group (left-of-issue marker).
191
+ GROUP_DISPLAY: dict[str, str] = {
192
+ "ORPHAN": "[ORPHAN] ",
193
+ "RESUME": "[RESUME] ",
194
+ "URGENT": "[URGENT] ",
195
+ "untriaged": "[untriaged] ",
196
+ "other": "[other] ",
197
+ "BLOCKED": "[BLOCKED] ",
198
+ }
199
+
200
+ #: Framework default for ``plan.policy.triageRankingLabels[]``. EMPTY per
201
+ #: the umbrella section 12 framework-vs-consumer-config boundary (see
202
+ #: Current Shape v2 amendment on #1128). Deft's specific ranking labels
203
+ #: ship in the consumer-example child of #1119 (#1186), NOT here.
204
+ DEFAULT_TRIAGE_RANKING_LABELS: list[str] = []
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Dataclasses
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class QueueItem:
214
+ """One ranked row in :func:`build_queue`.
215
+
216
+ ``group`` is one of :data:`GROUP_ORDER`. ``latest_decision`` is the
217
+ most-recent audit-log decision string (or ``None`` for untriaged
218
+ issues). ``matched_label`` is the ranking-label that placed the item
219
+ above its peers within the same group (or ``None`` when the framework
220
+ default ``updated_at``-desc ordering applies).
221
+ """
222
+
223
+ number: int
224
+ title: str
225
+ state: str
226
+ labels: tuple[str, ...]
227
+ updated_at: str
228
+ group: str
229
+ latest_decision: str | None
230
+ matched_label: str | None
231
+ repo: str
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class QueueBuildOptions:
236
+ """Bundled options for :func:`build_queue`.
237
+
238
+ Splitting these out keeps the function signature short and avoids the
239
+ multi-positional drift that PEP 8 / ruff would otherwise flag.
240
+ """
241
+
242
+ ranking_labels: tuple[str, ...] = ()
243
+ active_referenced: frozenset[int] = field(default_factory=frozenset)
244
+ #: Issue numbers in the ``ORPHAN`` group per D13 (#1132): open
245
+ #: children whose umbrella has closed. Routed above ``RESUME`` in
246
+ #: :data:`GROUP_ORDER`. Empty by default for back-compat with
247
+ #: callers that have not yet rebased onto D13.
248
+ orphan_issue_numbers: frozenset[int] = field(default_factory=frozenset)
249
+ #: Maps GitHub issue number -> the scope vBRIEF's ``plan.metadata.rank``
250
+ #: (#1419 Slice 1 / #987). Used as the intra-bucket tiebreaker applied
251
+ #: AFTER the consumer priority-label ordering and BEFORE the creation-
252
+ #: date fallback. Empty by default; the CLI path instead reads the
253
+ #: per-issue ``_metadata_rank`` annotation stamped by
254
+ #: :func:`load_cached_issues`, so both surfaces honour rank.
255
+ rank_by_number: Mapping[int, int] = field(default_factory=dict)
256
+ #: Issue numbers whose scope is *continuation* work (#1419 Slice 2 /
257
+ #: #987): a story whose ``plan.planRef`` parent epic has already
258
+ #: started (>=1 child completed OR a sibling active). Continuation
259
+ #: outranks net-new single-issue work ("stop starting, start
260
+ #: finishing"). Empty by default; the CLI path instead reads the
261
+ #: per-issue ``_continuation`` annotation stamped by
262
+ #: :func:`load_cached_issues`.
263
+ continuation_numbers: frozenset[int] = field(default_factory=frozenset)
264
+ #: Maps issue number -> a stable "epic started-at" ordering key used to
265
+ #: surface the OLDEST-started epic's continuation work first. Compared
266
+ #: lexicographically ascending. CLI path reads the per-issue
267
+ #: ``_continuation_order`` annotation instead.
268
+ continuation_order_by_number: Mapping[int, str] = field(default_factory=dict)
269
+ #: Maps issue number -> its capacity-bucket deficit (target-vs-actual;
270
+ #: positive == under target) from the Slice-4 accounting engine
271
+ #: (#1419 Slice 2). Among NET-NEW work the most-under-target bucket
272
+ #: (highest deficit) sorts first. Empty by default; the CLI path reads
273
+ #: the per-issue ``_bucket_deficit`` annotation.
274
+ deficit_by_number: Mapping[int, float] = field(default_factory=dict)
275
+ #: Optional ``finishBeforeStart`` policy (#1419 Slice 2). When True AND
276
+ #: :attr:`wip_at_cap` is True, the queue drops net-new scopes entirely
277
+ #: -- at/near ``wipCap`` only continuation work is promotable.
278
+ finish_before_start: bool = False
279
+ #: True when the in-flight WIP set is at/over ``plan.policy.wipCap``.
280
+ #: Gates the :attr:`finish_before_start` net-new filter above.
281
+ wip_at_cap: bool = False
282
+ #: Issue numbers whose linked vBRIEF is blocked (#1286): ``plan.status
283
+ #: == "blocked"`` OR an unresolved ``plan.metadata.swarm.depends_on``.
284
+ #: Demoted into the ``BLOCKED`` group by default unless
285
+ #: :attr:`include_blocked` is True. Empty by default; the CLI path
286
+ #: instead reads the per-issue ``_blocked`` annotation stamped by
287
+ #: :func:`load_cached_issues`.
288
+ blocked_issue_numbers: frozenset[int] = field(default_factory=frozenset)
289
+ #: When True, blocked items (#1286) are re-surfaced into their natural
290
+ #: group instead of being demoted into the ``BLOCKED`` group. Wired to
291
+ #: the ``--include-blocked`` CLI opt-in.
292
+ include_blocked: bool = False
293
+ limit: int | None = None
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Time helpers
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ def _utc_now() -> datetime:
302
+ return datetime.now(UTC)
303
+
304
+
305
+ def _utc_iso(dt: datetime | None = None) -> str:
306
+ return (dt or _utc_now()).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Repo resolution (#1238)
311
+ # ---------------------------------------------------------------------------
312
+ #
313
+ # ``task triage:queue`` used to exit 2 demanding ``--repo`` even from inside
314
+ # a clone whose ``git origin`` would resolve the repo, while sibling tools
315
+ # (``triage:bootstrap``, ``preflight_cache``) already inferred it. These
316
+ # helpers give ``triage_queue`` the same resolution chain so ``--repo``
317
+ # becomes optional when origin is set. The chain mirrors
318
+ # ``scripts/preflight_cache.py::_infer_repo_from_git`` /
319
+ # ``_resolve_repo`` so the framework keeps one grammar.
320
+
321
+
322
+ def _infer_repo_from_git(project_root: Path | None) -> str | None:
323
+ """Best-effort: read ``git remote get-url origin`` inside ``project_root``.
324
+
325
+ Returns ``"owner/name"`` on success, ``None`` otherwise. Mirrors
326
+ :func:`scripts.preflight_cache._infer_repo_from_git`: a stuck git proxy
327
+ (corporate VPN re-auth) is bounded by a 10s timeout so the resolver
328
+ never hangs the CLI, and ``git`` missing from PATH degrades to ``None``
329
+ rather than raising. The capture forces ``encoding="utf-8",
330
+ errors="replace"`` per the #1366 safe-subprocess rule so a non-UTF-8
331
+ locale codepage cannot crash the read.
332
+ """
333
+ cwd = str(project_root) if project_root is not None else None
334
+ try:
335
+ proc = subprocess.run(
336
+ ["git", "remote", "get-url", "origin"],
337
+ cwd=cwd,
338
+ capture_output=True,
339
+ text=True,
340
+ encoding="utf-8",
341
+ errors="replace",
342
+ check=False,
343
+ timeout=10,
344
+ )
345
+ except (FileNotFoundError, OSError, subprocess.SubprocessError):
346
+ return None
347
+ if proc.returncode != 0:
348
+ return None
349
+ url = (proc.stdout or "").strip()
350
+ if not url:
351
+ return None
352
+ # github.com/owner/name(.git) -- accepts ssh / https / git protocol.
353
+ cleaned = url.rstrip("/")
354
+ if cleaned.endswith(".git"):
355
+ cleaned = cleaned[: -len(".git")]
356
+ if "github.com" not in cleaned:
357
+ return None
358
+ tail = cleaned.split("github.com", 1)[1].lstrip(":/")
359
+ parts = tail.split("/")
360
+ if len(parts) >= 2 and parts[0] and parts[1]:
361
+ return f"{parts[0]}/{parts[1]}"
362
+ return None
363
+
364
+
365
+ def _resolve_repo(explicit: str | None, project_root: Path | None = None) -> str | None:
366
+ """Resolve the effective ``owner/name`` repo slug for triage verbs (#1238).
367
+
368
+ Resolution order, highest precedence first:
369
+
370
+ 1. ``explicit`` -- the ``--repo`` flag value. A cross-repo invocation
371
+ always wins.
372
+ 2. ``$DEFT_TRIAGE_REPO`` -- the environment override, mirroring
373
+ ``preflight_cache`` / ``triage_bootstrap``.
374
+ 3. ``git remote get-url origin`` parsed from inside ``project_root``
375
+ (or the current working directory) -- the common-case dev
376
+ experience that removes the papercut where an operator inside an
377
+ unambiguous clone had to repeat the slug on every call.
378
+
379
+ Returns ``None`` when none of the three sources resolve so the caller
380
+ can emit the canonical ``--repo OWNER/NAME (or $DEFT_TRIAGE_REPO) is
381
+ required.`` error (exit 2) with an actionable next step.
382
+ """
383
+ if explicit:
384
+ return explicit
385
+ env_repo = os.environ.get(ENV_TRIAGE_REPO, "").strip()
386
+ if env_repo:
387
+ return env_repo
388
+ return _infer_repo_from_git(project_root)
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # Typed-policy resolver + validator (plan.policy.triageRankingLabels[])
393
+ # ---------------------------------------------------------------------------
394
+
395
+
396
+ def _load_project_definition(project_root: Path | None = None) -> dict[str, Any] | None:
397
+ """Read ``vbrief/PROJECT-DEFINITION.vbrief.json``. Returns ``None`` if absent."""
398
+ root = project_root or Path.cwd()
399
+ path = root / PROJECT_DEFINITION_REL_PATH
400
+ if not path.is_file():
401
+ return None
402
+ try:
403
+ data = json.loads(path.read_text(encoding="utf-8"))
404
+ except (json.JSONDecodeError, OSError):
405
+ return None
406
+ return data if isinstance(data, dict) else None
407
+
408
+
409
+ def resolve_ranking_labels(
410
+ project_root: Path | None = None,
411
+ *,
412
+ project_definition: dict[str, Any] | None = None,
413
+ ) -> list[str]:
414
+ """Resolve the effective ``plan.policy.triageRankingLabels`` list.
415
+
416
+ Resolution order:
417
+
418
+ 1. If a non-empty list of strings is set on
419
+ ``plan.policy.triageRankingLabels``, return its filtered copy.
420
+ 2. Otherwise (unset / missing / non-list / empty), return the
421
+ framework default (an empty list).
422
+
423
+ Per the umbrella section 12 framework-vs-consumer-config boundary
424
+ the framework MUST NOT ship label values here. Consumer-specific
425
+ labels (`urgent`, `breaking-change`, `blocks-merge`,
426
+ `adoption-blocker`) live in the deft consumer-example child of
427
+ #1119 (#1186), which loads on top of the framework default at
428
+ runtime.
429
+ """
430
+ data = (
431
+ project_definition
432
+ if project_definition is not None
433
+ else _load_project_definition(project_root)
434
+ )
435
+ if not isinstance(data, dict):
436
+ return list(DEFAULT_TRIAGE_RANKING_LABELS)
437
+ plan = data.get("plan")
438
+ if not isinstance(plan, dict):
439
+ return list(DEFAULT_TRIAGE_RANKING_LABELS)
440
+ policy = plan.get("policy")
441
+ if not isinstance(policy, dict):
442
+ return list(DEFAULT_TRIAGE_RANKING_LABELS)
443
+ value = policy.get("triageRankingLabels")
444
+ if not isinstance(value, list) or not value:
445
+ return list(DEFAULT_TRIAGE_RANKING_LABELS)
446
+ return [s for s in value if isinstance(s, str) and s]
447
+
448
+
449
+ def validate_ranking_labels(value: Any) -> tuple[list[str], list[str]]:
450
+ """Validate a ``plan.policy.triageRankingLabels`` payload.
451
+
452
+ Returns ``(errors, warnings)``. ``errors`` is empty on success.
453
+
454
+ Validation rules:
455
+
456
+ * Unset / ``None`` is fine (handled by :func:`resolve_ranking_labels`
457
+ with the empty framework default).
458
+ * The top-level value MUST be a list when set.
459
+ * Empty list is accepted (equivalent to unset).
460
+ * Every entry MUST be a non-empty string.
461
+ * Duplicate labels surface as a warning so consumers see the typo
462
+ without rejecting an otherwise-valid configuration.
463
+ """
464
+ errors: list[str] = []
465
+ warnings: list[str] = []
466
+ if value is None:
467
+ return errors, warnings
468
+ if not isinstance(value, list):
469
+ errors.append(
470
+ f"plan.policy.triageRankingLabels must be a list of strings; got {type(value).__name__}"
471
+ )
472
+ return errors, warnings
473
+ seen: set[str] = set()
474
+ for i, entry in enumerate(value):
475
+ prefix = f"plan.policy.triageRankingLabels[{i}]"
476
+ if not isinstance(entry, str):
477
+ errors.append(f"{prefix} must be a string, got {type(entry).__name__}")
478
+ continue
479
+ if not entry.strip():
480
+ errors.append(f"{prefix} must be a non-empty string")
481
+ continue
482
+ if entry in seen:
483
+ warnings.append(f"{prefix} duplicate label {entry!r}; only the first occurrence ranks")
484
+ seen.add(entry)
485
+ return errors, warnings
486
+
487
+
488
+ def validate_triage_ranking_labels_on_plan(plan: Any, filepath: Any) -> list[str]:
489
+ """vbrief_validate hook: validate ``plan.policy.triageRankingLabels`` (#1128).
490
+
491
+ Returns formatted error strings prefixed with ``<filepath>:`` so
492
+ ``vbrief_validate.validate_project_definition`` can splice them into
493
+ its existing error list without re-formatting. Unset / missing is
494
+ treated as the framework default and returns an empty list.
495
+ """
496
+ out: list[str] = []
497
+ if not isinstance(plan, dict):
498
+ return out
499
+ policy = plan.get("policy")
500
+ raw = policy.get("triageRankingLabels") if isinstance(policy, dict) else None
501
+ if raw is None:
502
+ return out
503
+ errors, _warnings = validate_ranking_labels(raw)
504
+ for err in errors:
505
+ out.append(f"{filepath}: {err} (#1128)")
506
+ return out
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # Group derivation
511
+ # ---------------------------------------------------------------------------
512
+
513
+
514
+ def derive_group(latest_decision: str | None, in_active_vbrief: bool) -> str:
515
+ """Map ``(latest_decision, in_active_vbrief)`` to a group bucket.
516
+
517
+ Rules (framework-universal; no consumer labels involved):
518
+
519
+ * ``in_active_vbrief`` -> ``"RESUME"``: there is an active vBRIEF
520
+ referencing this issue, so the operator already declared an
521
+ implementation intent against it; the queue surfaces it first so
522
+ the operator can resume the running work.
523
+ * ``latest_decision == "resume-eligible"`` -> ``"RESUME"``: D3
524
+ (#1123) appended a ``resume-eligible`` marker because the prior
525
+ ``defer``'s ``resume_on`` condition fired. The operator should
526
+ revisit the defer with current data; the queue surfaces it in
527
+ the same bucket as active-vBRIEF resumes.
528
+ * ``latest_decision == "needs-ac"`` -> ``"URGENT"``: the operator
529
+ previously asked the reporter for acceptance criteria; the issue
530
+ is in a holding pattern that requires attention.
531
+ * ``latest_decision is None`` -> ``"untriaged"``: no decision has
532
+ been recorded for this issue yet -- it needs an initial triage
533
+ pass.
534
+ * Otherwise -> ``"other"``: a terminal decision (accept / reject /
535
+ defer / mark-duplicate / reset) is recorded but no active vBRIEF
536
+ links to it.
537
+
538
+ The order matters: ``RESUME`` takes priority over ``URGENT`` so
539
+ an issue that was once flagged ``needs-ac`` and has since been
540
+ re-accepted into an active vBRIEF (or had a resume condition fire)
541
+ surfaces in the resumable bucket, not the holding-pattern bucket.
542
+ """
543
+ if in_active_vbrief:
544
+ return "RESUME"
545
+ if latest_decision == "resume-eligible":
546
+ return "RESUME"
547
+ if latest_decision == "needs-ac":
548
+ return "URGENT"
549
+ if latest_decision is None:
550
+ return "untriaged"
551
+ return "other"
552
+
553
+
554
+ # ---------------------------------------------------------------------------
555
+ # plan.metadata.rank ordering + spec-readiness (#1419 Slice 1 / #987)
556
+ # ---------------------------------------------------------------------------
557
+
558
+
559
+ def scope_metadata_rank(plan: Any) -> int | None:
560
+ """Return ``plan.metadata.rank`` as an int, or ``None`` when absent/invalid.
561
+
562
+ Accepts a real integer or an integer-valued string (tolerating the
563
+ JSON-as-string shape some hand-authored vBRIEFs use, including a
564
+ leading-minus negative). ``bool`` is rejected even though it subclasses
565
+ ``int`` -- a ``true`` rank is meaningless. Any other non-integer string
566
+ (e.g. ``"--3"``, ``"x"``, ``""``) returns ``None`` rather than raising:
567
+ ``int()`` inside a ``try`` is the correct guard, since a prefix check
568
+ like ``lstrip("-").isdigit()`` wrongly admits ``"--3"``.
569
+
570
+ ``scripts/roadmap_render._scope_metadata_rank`` is a deliberate mirror
571
+ of this function: the renderer keeps its own tiny pure copy so it stays
572
+ decoupled from this module's triage-cache dependency surface. Both are
573
+ covered by tests (including the malformed-string edge case) so the
574
+ shared semantics cannot silently drift.
575
+ """
576
+ if not isinstance(plan, dict):
577
+ return None
578
+ metadata = plan.get("metadata")
579
+ if not isinstance(metadata, dict):
580
+ return None
581
+ rank = metadata.get("rank")
582
+ if isinstance(rank, bool):
583
+ return None
584
+ if isinstance(rank, int):
585
+ return rank
586
+ if isinstance(rank, str):
587
+ try:
588
+ return int(rank.strip())
589
+ except ValueError:
590
+ return None
591
+ return None
592
+
593
+
594
+ def _issue_numbers_from_plan(plan: dict[str, Any]) -> set[int]:
595
+ """Extract issue numbers from a plan's ``x-vbrief/github-issue`` references."""
596
+ out: set[int] = set()
597
+ refs = plan.get("references") if isinstance(plan, dict) else None
598
+ if not isinstance(refs, list):
599
+ return out
600
+ for ref in refs:
601
+ if not isinstance(ref, dict) or ref.get("type") != "x-vbrief/github-issue":
602
+ continue
603
+ uri = ref.get("uri", "")
604
+ if not isinstance(uri, str):
605
+ continue
606
+ tail = uri.rstrip("/").rsplit("/", 1)[-1]
607
+ if tail.isdigit():
608
+ out.add(int(tail))
609
+ return out
610
+
611
+
612
+ def _rank_by_issue_number(
613
+ project_root: Path | None,
614
+ *,
615
+ folders: tuple[str, ...] = ("pending", "active"),
616
+ ) -> dict[int, int]:
617
+ """Map referenced issue numbers to their scope vBRIEF ``plan.metadata.rank``.
618
+
619
+ Walks ``vbrief/<folder>/*.vbrief.json`` for each folder in ``folders``
620
+ (default: the in-flight ``pending`` + ``active`` scopes the queue
621
+ ranks), reads ``plan.metadata.rank`` (#1419 Slice 1 / #987) and maps
622
+ every GitHub issue number that scope references to the rank. Files are
623
+ visited in sorted filename order and the first rank seen for an issue
624
+ wins, so the mapping is deterministic. Scopes without an integer rank
625
+ contribute nothing -- those issues tail-sort after ranked ones.
626
+ """
627
+ out: dict[int, int] = {}
628
+ base = (project_root or Path.cwd()) / "vbrief"
629
+ for folder in folders:
630
+ folder_dir = base / folder
631
+ if not folder_dir.is_dir():
632
+ continue
633
+ for path in sorted(folder_dir.glob("*.vbrief.json")):
634
+ try:
635
+ data = json.loads(path.read_text(encoding="utf-8"))
636
+ except (json.JSONDecodeError, OSError):
637
+ continue
638
+ plan = data.get("plan") if isinstance(data, dict) else None
639
+ if not isinstance(plan, dict):
640
+ continue
641
+ rank = scope_metadata_rank(plan)
642
+ if rank is None:
643
+ continue
644
+ for number in _issue_numbers_from_plan(plan):
645
+ out.setdefault(number, rank)
646
+ return out
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # Continuation precedence + deficit-biased selection (#1419 Slice 2 / #987)
651
+ # ---------------------------------------------------------------------------
652
+
653
+
654
+ def _load_plan(path: Path) -> dict[str, Any] | None:
655
+ """Read a vBRIEF file and return its ``plan`` block, or ``None``."""
656
+ try:
657
+ data = json.loads(path.read_text(encoding="utf-8"))
658
+ except (json.JSONDecodeError, OSError):
659
+ return None
660
+ if not isinstance(data, dict):
661
+ return None
662
+ plan = data.get("plan")
663
+ return plan if isinstance(plan, dict) else None
664
+
665
+
666
+ def _epic_child_refs(epic_path: Path) -> list[tuple[str, str]]:
667
+ """Return ``[(folder, basename), ...]`` for an epic's ``x-vbrief/plan`` children.
668
+
669
+ Reads the parent epic's ``plan.references[]`` (the canonical
670
+ parent->child link, e.g. ``"completed/<slug>.vbrief.json"``) and yields
671
+ the lifecycle folder + filename of each child so the caller can decide
672
+ whether the epic has started.
673
+ """
674
+ plan = _load_plan(epic_path)
675
+ if plan is None:
676
+ return []
677
+ refs = plan.get("references")
678
+ if not isinstance(refs, list):
679
+ return []
680
+ out: list[tuple[str, str]] = []
681
+ for ref in refs:
682
+ if not isinstance(ref, dict) or ref.get("type") != "x-vbrief/plan":
683
+ continue
684
+ uri = ref.get("uri")
685
+ if not isinstance(uri, str) or not uri.strip():
686
+ continue
687
+ rel = uri.replace("\\", "/")
688
+ folder = rel.split("/", 1)[0] if "/" in rel else ""
689
+ basename = rel.rsplit("/", 1)[-1]
690
+ out.append((folder, basename))
691
+ return out
692
+
693
+
694
+ def _epic_started(child_refs: list[tuple[str, str]], *, exclude_name: str) -> bool:
695
+ """True when an epic has STARTED: >=1 child completed OR a sibling active.
696
+
697
+ ``exclude_name`` is the candidate scope's filename so a single active
698
+ child that IS the candidate itself does not make the candidate count as
699
+ its own continuation -- a sibling active child (a different filename) or
700
+ any completed child is required.
701
+ """
702
+ for folder, basename in child_refs:
703
+ if folder == "completed":
704
+ return True
705
+ if folder == "active" and basename != exclude_name:
706
+ return True
707
+ return False
708
+
709
+
710
+ def continuation_by_issue_number(
711
+ project_root: Path | None,
712
+ *,
713
+ folders: tuple[str, ...] = ("pending", "active"),
714
+ ) -> dict[int, str]:
715
+ """Map referenced issue numbers -> a continuation ordering key (#1419 Slice 2).
716
+
717
+ A scope is *continuation* work when its ``plan.planRef`` parent epic has
718
+ already STARTED (>=1 child completed OR a sibling active per
719
+ :func:`_epic_started`). Walks the in-flight ``pending`` + ``active``
720
+ scopes, resolves each one's parent epic, and maps every GitHub issue a
721
+ continuation scope references to a stable ordering key -- the parent
722
+ epic's date-prefixed filename -- so the OLDEST-started epic's work sorts
723
+ first. Net-new scopes (no started parent epic) contribute nothing.
724
+ """
725
+ out: dict[int, str] = {}
726
+ base = (project_root or Path.cwd()) / "vbrief"
727
+ child_refs_cache: dict[Path, list[tuple[str, str]]] = {}
728
+ for folder in folders:
729
+ folder_dir = base / folder
730
+ if not folder_dir.is_dir():
731
+ continue
732
+ for path in sorted(folder_dir.glob("*.vbrief.json")):
733
+ plan = _load_plan(path)
734
+ if plan is None:
735
+ continue
736
+ plan_ref = plan.get("planRef")
737
+ if not isinstance(plan_ref, str) or not plan_ref.strip():
738
+ continue
739
+ epic_path = (base / plan_ref).resolve()
740
+ if epic_path not in child_refs_cache:
741
+ child_refs_cache[epic_path] = _epic_child_refs(epic_path)
742
+ if not _epic_started(child_refs_cache[epic_path], exclude_name=path.name):
743
+ continue
744
+ order_key = epic_path.name
745
+ for number in _issue_numbers_from_plan(plan):
746
+ out.setdefault(number, order_key)
747
+ return out
748
+
749
+
750
+ def bucket_deficit_by_issue_number(
751
+ project_root: Path | None,
752
+ *,
753
+ folders: tuple[str, ...] = ("pending", "active"),
754
+ ) -> dict[int, float]:
755
+ """Map referenced issue numbers -> their capacity-bucket deficit (#1419 Slice 2).
756
+
757
+ Reads the per-bucket target-vs-actual deficit from the Slice-4 capacity
758
+ accounting engine (:func:`capacity_show.compute_report`; IMPORT-ONLY,
759
+ never edited) and maps each in-flight scope to its bucket's deficit via
760
+ ``plan.metadata.capacityBucket`` (falling back to the policy
761
+ ``defaultBucket``). A positive deficit means the bucket is UNDER target,
762
+ so the most-under-target bucket sorts first among net-new work.
763
+ Best-effort: returns ``{}`` when the capacity engine / policy module is
764
+ unavailable or errors so an advisory signal never breaks the queue.
765
+ """
766
+ if capacity_show is None:
767
+ return {}
768
+ root = project_root or Path.cwd()
769
+ try:
770
+ report = capacity_show.compute_report(root)
771
+ except Exception: # noqa: BLE001 -- advisory signal must not break the queue
772
+ return {}
773
+ deficits = {tally.bucket_id: report.bucket_deficit(tally) for tally in report.buckets}
774
+ if not deficits:
775
+ return {}
776
+ default_bucket = ""
777
+ if _policy is not None:
778
+ try:
779
+ default_bucket = _policy.resolve_capacity_allocation(root).default_bucket
780
+ except Exception: # noqa: BLE001 -- advisory; fall back to no default bucket
781
+ default_bucket = ""
782
+ out: dict[int, float] = {}
783
+ base = root / "vbrief"
784
+ for folder in folders:
785
+ folder_dir = base / folder
786
+ if not folder_dir.is_dir():
787
+ continue
788
+ for path in sorted(folder_dir.glob("*.vbrief.json")):
789
+ plan = _load_plan(path)
790
+ if plan is None:
791
+ continue
792
+ raw_metadata = plan.get("metadata")
793
+ metadata = raw_metadata if isinstance(raw_metadata, dict) else {}
794
+ raw_bucket = metadata.get("capacityBucket")
795
+ bucket = (
796
+ raw_bucket.strip()
797
+ if isinstance(raw_bucket, str) and raw_bucket.strip()
798
+ else default_bucket
799
+ )
800
+ if bucket not in deficits:
801
+ continue
802
+ for number in _issue_numbers_from_plan(plan):
803
+ out.setdefault(number, deficits[bucket])
804
+ return out
805
+
806
+
807
+ def resolve_finish_before_start(project_root: Path | None = None) -> bool:
808
+ """Read the optional ``capacityAllocation.finishBeforeStart`` policy (#1419 Slice 2).
809
+
810
+ Read directly from PROJECT-DEFINITION because the typed ``policy.py``
811
+ surface does not expose this advisory field. Defaults to ``False`` -- the
812
+ hard finish-before-start variant is opt-in. Callers pair this with
813
+ :func:`wip_at_cap` to set :attr:`QueueBuildOptions.finish_before_start`
814
+ and :attr:`QueueBuildOptions.wip_at_cap`.
815
+ """
816
+ data = _load_project_definition(project_root)
817
+ if not isinstance(data, dict):
818
+ return False
819
+ plan = data.get("plan")
820
+ policy = plan.get("policy") if isinstance(plan, dict) else None
821
+ cap = policy.get("capacityAllocation") if isinstance(policy, dict) else None
822
+ return isinstance(cap, dict) and cap.get("finishBeforeStart") is True
823
+
824
+
825
+ def wip_at_cap(project_root: Path | None = None) -> bool:
826
+ """True when the in-flight WIP set is at/over ``plan.policy.wipCap`` (#1419 Slice 2).
827
+
828
+ Reuses the ``scripts/policy.py`` WIP-cap surface (IMPORT-ONLY). Returns
829
+ ``False`` when the policy module is unavailable so the finishBeforeStart
830
+ gate stays inert on a slim checkout.
831
+ """
832
+ if _policy is None:
833
+ return False
834
+ root = project_root or Path.cwd()
835
+ try:
836
+ cap = _policy.resolve_wip_cap(root).cap
837
+ count = _policy.count_vbrief_wip(root)
838
+ except Exception: # noqa: BLE001 -- advisory gate must not break the queue
839
+ return False
840
+ return count >= cap
841
+
842
+
843
+ # ---------------------------------------------------------------------------
844
+ # Blocked / unresolved-dependency demotion (#1286)
845
+ # ---------------------------------------------------------------------------
846
+
847
+
848
+ def _depends_on_ids(plan: dict[str, Any]) -> list[str]:
849
+ """Return the non-empty string ids in ``plan.metadata.swarm.depends_on``.
850
+
851
+ Tolerant of the absent / non-list / non-string shapes so a malformed
852
+ swarm block never raises -- it simply contributes no dependency ids.
853
+ """
854
+ metadata = plan.get("metadata") if isinstance(plan, dict) else None
855
+ swarm = metadata.get("swarm") if isinstance(metadata, dict) else None
856
+ raw = swarm.get("depends_on") if isinstance(swarm, dict) else None
857
+ if not isinstance(raw, list):
858
+ return []
859
+ return [dep.strip() for dep in raw if isinstance(dep, str) and dep.strip()]
860
+
861
+
862
+ def _completed_plan_ids(project_root: Path | None) -> set[str]:
863
+ """Return the set of ``plan.id`` values from ``vbrief/completed/``.
864
+
865
+ Used to decide whether a scope's ``depends_on`` entries are resolved:
866
+ a dependency id is resolved when a completed scope carries that id.
867
+ """
868
+ out: set[str] = set()
869
+ base = (project_root or Path.cwd()) / "vbrief" / "completed"
870
+ if not base.is_dir():
871
+ return out
872
+ for path in sorted(base.glob("*.vbrief.json")):
873
+ plan = _load_plan(path)
874
+ if plan is None:
875
+ continue
876
+ pid = plan.get("id")
877
+ if isinstance(pid, str) and pid.strip():
878
+ out.add(pid.strip())
879
+ return out
880
+
881
+
882
+ def scope_is_blocked(plan: Any, *, completed_ids: set[str]) -> bool:
883
+ """Return True when a scope's linked vBRIEF is blocked (#1286).
884
+
885
+ A scope is blocked when EITHER:
886
+
887
+ * ``plan.status == "blocked"`` -- the operator explicitly parked it, OR
888
+ * ``plan.metadata.swarm.depends_on`` is non-empty and at least one
889
+ dependency id is unresolved (no completed scope carries that id).
890
+
891
+ ``completed_ids`` is the set of ``plan.id`` values from completed
892
+ scopes (see :func:`_completed_plan_ids`); an empty set treats every
893
+ declared dependency as unresolved, which is the safe default when the
894
+ completed lifecycle folder is unavailable.
895
+ """
896
+ if not isinstance(plan, dict):
897
+ return False
898
+ if plan.get("status") == "blocked":
899
+ return True
900
+ deps = _depends_on_ids(plan)
901
+ return bool(deps) and any(dep not in completed_ids for dep in deps)
902
+
903
+
904
+ def blocked_by_issue_number(
905
+ project_root: Path | None,
906
+ *,
907
+ folders: tuple[str, ...] = ("pending", "active"),
908
+ ) -> set[int]:
909
+ """Map referenced issue numbers -> blocked state (#1286).
910
+
911
+ Walks the in-flight ``pending`` + ``active`` scopes (the folders the
912
+ queue ranks), flags each one via :func:`scope_is_blocked`, and returns
913
+ the set of GitHub issue numbers a blocked scope references. The
914
+ ``vbrief/completed/`` plan ids are read once up front so unresolved-
915
+ dependency detection is consistent across the walk.
916
+ """
917
+ out: set[int] = set()
918
+ root = project_root or Path.cwd()
919
+ completed_ids = _completed_plan_ids(root)
920
+ base = root / "vbrief"
921
+ for folder in folders:
922
+ folder_dir = base / folder
923
+ if not folder_dir.is_dir():
924
+ continue
925
+ for path in sorted(folder_dir.glob("*.vbrief.json")):
926
+ plan = _load_plan(path)
927
+ if plan is None:
928
+ continue
929
+ if not scope_is_blocked(plan, completed_ids=completed_ids):
930
+ continue
931
+ out |= _issue_numbers_from_plan(plan)
932
+ return out
933
+
934
+
935
+ #: Operator-facing pointer surfaced when a scope is refused as under-specified
936
+ #: (#1419 Slice 1 / #987). Names refinement as the canonical next step.
937
+ SPEC_READINESS_REFINEMENT_HINT = (
938
+ "refine the scope via skills/deft-directive-refinement "
939
+ "(`task triage:welcome --onboard`) before promotion/selection"
940
+ )
941
+
942
+
943
+ def scope_spec_readiness(plan: Any) -> tuple[bool, list[str]]:
944
+ """Return ``(eligible, reasons)`` for a scope's spec-readiness (#987 / #1419).
945
+
946
+ Reuses the existing swarm-readiness / story-quality contract
947
+ (:mod:`_vbrief_story_quality`) instead of inventing a parallel field
948
+ set: a scope is eligible for promotion/selection only when it declares
949
+ ``plan.metadata.swarm.readiness == "ready"``, carries the required
950
+ swarm fields, the three required narratives, and at least one
951
+ acceptance criterion. ``reasons`` lists the missing fields when
952
+ ineligible and is empty when eligible. On a slim checkout where
953
+ :mod:`_vbrief_story_quality` is unimportable the check degrades to the
954
+ ``swarm.readiness`` gate alone so an unmarked scope is still refused.
955
+ """
956
+ if not isinstance(plan, dict):
957
+ return False, ["plan is not an object"]
958
+ raw_metadata = plan.get("metadata")
959
+ metadata = raw_metadata if isinstance(raw_metadata, dict) else {}
960
+ raw_swarm = metadata.get("swarm")
961
+ swarm = raw_swarm if isinstance(raw_swarm, dict) else {}
962
+ reasons: list[str] = []
963
+ if swarm.get("readiness") != "ready":
964
+ reasons.append("plan.metadata.swarm.readiness=ready")
965
+ if _vbrief_story_quality is not None:
966
+ reasons.extend(_vbrief_story_quality.missing_required_swarm_fields(swarm))
967
+ raw_narratives = plan.get("narratives")
968
+ narratives = raw_narratives if isinstance(raw_narratives, dict) else {}
969
+ for key in ("Description", "ImplementationPlan", "UserStory"):
970
+ value = narratives.get(key)
971
+ if not (isinstance(value, str) and value.strip()):
972
+ reasons.append(f"plan.narratives.{key}")
973
+ if not _vbrief_story_quality.items_have_acceptance(plan.get("items")):
974
+ reasons.append("plan.items[].narrative.Acceptance")
975
+ return (not reasons), reasons
976
+
977
+
978
+ def spec_readiness_refusal(plan: Any, *, scope_label: str = "scope") -> str | None:
979
+ """Return a refusal message when ``plan`` is under-specified, else ``None``.
980
+
981
+ The message names the missing spec-readiness fields and points the
982
+ operator at refinement (#987 / #1419). Returns ``None`` when the scope
983
+ is eligible so callers can guard with
984
+ ``if (msg := spec_readiness_refusal(plan)): refuse(msg)``.
985
+ """
986
+ eligible, reasons = scope_spec_readiness(plan)
987
+ if eligible:
988
+ return None
989
+ detail = ", ".join(reasons)
990
+ return (
991
+ f"{scope_label}: refusing promotion/selection -- under-specified "
992
+ f"(missing: {detail}); {SPEC_READINESS_REFINEMENT_HINT}"
993
+ )
994
+
995
+
996
+ # ---------------------------------------------------------------------------
997
+ # Cache walk
998
+ # ---------------------------------------------------------------------------
999
+
1000
+
1001
+ def cache_root_for(project_root: Path | None = None) -> Path:
1002
+ root = project_root or Path.cwd()
1003
+ return root / CACHE_DIR_NAME
1004
+
1005
+
1006
+ def repo_cache_path(
1007
+ repo: str,
1008
+ *,
1009
+ project_root: Path | None = None,
1010
+ source: str = CACHE_SOURCE_GITHUB_ISSUE,
1011
+ ) -> Path:
1012
+ """Return ``<cache>/<source>/<owner>/<name>/`` for ``repo='owner/name'``."""
1013
+ if "/" not in repo:
1014
+ raise ValueError(f"repo must be 'owner/name'; got {repo!r}")
1015
+ owner, name = repo.split("/", 1)
1016
+ return cache_root_for(project_root) / source / owner / name
1017
+
1018
+
1019
+ def _read_meta_fetched_at(entry_dir: Path) -> str | None:
1020
+ """Return the sibling ``meta.json``'s ``fetched_at`` string, or ``None``.
1021
+
1022
+ Used by the #1476 defensive stale-state path to date a cached entry
1023
+ without importing the cache layer's validator (pure read).
1024
+ """
1025
+ meta_path = entry_dir / "meta.json"
1026
+ if not meta_path.is_file():
1027
+ return None
1028
+ try:
1029
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
1030
+ except (json.JSONDecodeError, OSError):
1031
+ return None
1032
+ if not isinstance(meta, dict):
1033
+ return None
1034
+ fetched = meta.get("fetched_at")
1035
+ return fetched if isinstance(fetched, str) else None
1036
+
1037
+
1038
+ def _entry_is_stale(
1039
+ entry_dir: Path,
1040
+ *,
1041
+ max_age_hours: int | None,
1042
+ now: datetime | None,
1043
+ ) -> bool:
1044
+ """True when ``entry_dir``'s cached ``fetched_at`` is past the freshness window.
1045
+
1046
+ Delegates the window resolution to :func:`preflight_cache.is_fetched_at_stale`
1047
+ so #1127 / #1476 share one definition. When :mod:`preflight_cache` is
1048
+ not importable the defensive path is disabled (returns ``False``) so
1049
+ the queue never mass-re-resolves on a partial checkout.
1050
+ """
1051
+ if preflight_cache is None:
1052
+ return False
1053
+ fetched_at = _read_meta_fetched_at(entry_dir)
1054
+ return preflight_cache.is_fetched_at_stale(fetched_at, max_age_hours=max_age_hours, now=now)
1055
+
1056
+
1057
+ def _resolve_live_state(
1058
+ state_resolver: Callable[[str, int], str | None],
1059
+ repo: str,
1060
+ number: int,
1061
+ ) -> str | None:
1062
+ """Call ``state_resolver`` and normalise its result to a lowercase state.
1063
+
1064
+ A resolver failure returns ``None`` (unknown) so a transient network
1065
+ error never drops a genuinely-open entry from the queue.
1066
+ """
1067
+ try:
1068
+ result = state_resolver(repo, number)
1069
+ except Exception: # noqa: BLE001 -- resolver failure must not drop the entry
1070
+ return None
1071
+ return result.lower() if isinstance(result, str) else None
1072
+
1073
+
1074
+ def load_cached_issues(
1075
+ repo: str,
1076
+ *,
1077
+ project_root: Path | None = None,
1078
+ source: str = CACHE_SOURCE_GITHUB_ISSUE,
1079
+ include_closed: bool = False,
1080
+ state_resolver: Callable[[str, int], str | None] | None = None,
1081
+ max_age_hours: int | None = None,
1082
+ now: datetime | None = None,
1083
+ ) -> list[dict[str, Any]]:
1084
+ """Walk the cache and return one dict per cached issue.
1085
+
1086
+ Each dict carries at least: ``number``, ``title``, ``state``,
1087
+ ``labels`` (list of strings), ``updated_at``, ``created_at``, and
1088
+ ``_metadata_rank`` -- the scope vBRIEF ``plan.metadata.rank`` for this
1089
+ issue (or ``None``), threaded as the intra-bucket tiebreaker by #1419
1090
+ Slice 1 (#987). Missing fields are filled with empty / sentinel values
1091
+ rather than raising so a partially-populated cache (mid-fetch) still
1092
+ produces a usable queue.
1093
+
1094
+ Closed issues are excluded by default; pass ``include_closed=True``
1095
+ to surface them too (used by :func:`audit` callers that need full
1096
+ history).
1097
+
1098
+ Defensive stale-state handling (#1476): ``cache:fetch-all`` defaults
1099
+ to ``state=open`` and never rewrites a cached entry that closed
1100
+ upstream within its TTL, so a closed issue can keep saying
1101
+ ``state=open`` on disk and surface as actionable ``triage:queue``
1102
+ work (the #1322 shape). When an optional ``state_resolver`` callable
1103
+ is supplied, a cached-open entry whose ``meta.json`` ``fetched_at``
1104
+ is older than the freshness window (``max_age_hours`` / the
1105
+ ``DEFT_CACHE_MAX_AGE_HOURS`` env / 24h default) is re-resolved
1106
+ against it; a ``closed`` result is honoured so the entry is excluded
1107
+ (unless ``include_closed``). The resolver is OFF by default -- the
1108
+ cache-side reconciliation (``cache:fetch-all --refresh-closed``) is
1109
+ the primary fix and this is the read-side belt-and-suspenders.
1110
+ """
1111
+ base = repo_cache_path(repo, project_root=project_root, source=source)
1112
+ if not base.is_dir():
1113
+ return []
1114
+ # #1419 Slice 1 (#987): resolve plan.metadata.rank per referenced issue
1115
+ # from the in-flight scope vBRIEFs so the CLI path orders by rank
1116
+ # without _triage_queue_cli.py needing to thread an extra argument.
1117
+ rank_map = _rank_by_issue_number(project_root)
1118
+ # #1419 Slice 2 (#987): annotate continuation precedence + bucket deficit
1119
+ # from filesystem-truth so the CLI ordering matches the programmatic
1120
+ # surface without the cli shim threading extra arguments.
1121
+ continuation_map = continuation_by_issue_number(project_root)
1122
+ deficit_map = bucket_deficit_by_issue_number(project_root)
1123
+ # #1286: flag issues whose linked vBRIEF is blocked (status:blocked or an
1124
+ # unresolved swarm.depends_on) so build_queue can demote them.
1125
+ blocked_set = blocked_by_issue_number(project_root)
1126
+ issues: list[dict[str, Any]] = []
1127
+ for entry in base.iterdir():
1128
+ if not entry.is_dir() or not entry.name.isdigit():
1129
+ continue
1130
+ raw_path = entry / "raw.json"
1131
+ if not raw_path.is_file():
1132
+ continue
1133
+ try:
1134
+ payload = json.loads(raw_path.read_text(encoding="utf-8"))
1135
+ except (json.JSONDecodeError, OSError):
1136
+ continue
1137
+ if not isinstance(payload, dict):
1138
+ continue
1139
+ n = payload.get("number")
1140
+ if not isinstance(n, int):
1141
+ with contextlib.suppress(ValueError, TypeError):
1142
+ n = int(entry.name)
1143
+ if not isinstance(n, int):
1144
+ continue
1145
+ # #1236 defensive normalisation: pre-#1239 cached payloads carry
1146
+ # GraphQL-shape uppercase ``"state": "OPEN"``; post-#1239 the REST
1147
+ # writer canonicalises to lowercase. The reader MUST treat both
1148
+ # as equivalent so any existing cache populated before the
1149
+ # writer migration still surfaces open issues.
1150
+ state_raw = payload.get("state") or "open"
1151
+ state = state_raw.lower() if isinstance(state_raw, str) else "open"
1152
+ # #1476 defensive stale-state re-resolution (opt-in via state_resolver).
1153
+ if (
1154
+ state == "open"
1155
+ and state_resolver is not None
1156
+ and _entry_is_stale(entry, max_age_hours=max_age_hours, now=now)
1157
+ ):
1158
+ resolved = _resolve_live_state(state_resolver, repo, int(n))
1159
+ if resolved is not None:
1160
+ state = resolved
1161
+ if state != "open" and not include_closed:
1162
+ continue
1163
+ title = payload.get("title") or ""
1164
+ updated_at = payload.get("updated_at") or ""
1165
+ created_at = payload.get("created_at") or ""
1166
+ labels_raw = payload.get("labels", [])
1167
+ labels: list[str] = []
1168
+ if isinstance(labels_raw, list):
1169
+ for item in labels_raw:
1170
+ if isinstance(item, dict):
1171
+ name = item.get("name")
1172
+ if isinstance(name, str):
1173
+ labels.append(name)
1174
+ elif isinstance(item, str):
1175
+ labels.append(item)
1176
+ issues.append(
1177
+ {
1178
+ "number": int(n),
1179
+ "title": title,
1180
+ "state": state,
1181
+ "labels": labels,
1182
+ "updated_at": updated_at,
1183
+ "created_at": created_at,
1184
+ "_metadata_rank": rank_map.get(int(n)),
1185
+ "_continuation": int(n) in continuation_map,
1186
+ "_continuation_order": continuation_map.get(int(n), ""),
1187
+ "_bucket_deficit": deficit_map.get(int(n)),
1188
+ "_blocked": int(n) in blocked_set,
1189
+ }
1190
+ )
1191
+ return issues
1192
+
1193
+
1194
+ # ---------------------------------------------------------------------------
1195
+ # Audit-log helpers
1196
+ # ---------------------------------------------------------------------------
1197
+
1198
+
1199
+ def _resolve_audit_log(audit_path: Path | str | None) -> Any:
1200
+ """Resolve the ``candidates_log`` module + path the CLI uses.
1201
+
1202
+ Returns the (module, path) pair the read helpers below pass through.
1203
+ The path is forwarded to :func:`candidates_log.read_all`'s ``path=``
1204
+ parameter so tests can route reads to a tmp log without monkeypatching
1205
+ a constant.
1206
+ """
1207
+ return candidates_log, audit_path
1208
+
1209
+
1210
+ def read_audit_entries(
1211
+ repo: str | None,
1212
+ *,
1213
+ audit_path: Path | str | None = None,
1214
+ ) -> list[dict[str, Any]]:
1215
+ """Return all audit entries (optionally filtered by ``repo``)."""
1216
+ mod, path = _resolve_audit_log(audit_path)
1217
+ if mod is None:
1218
+ return []
1219
+ return list(mod.read_all(repo=repo, path=path))
1220
+
1221
+
1222
+ def latest_decisions_by_issue(
1223
+ entries: Iterable[dict[str, Any]],
1224
+ ) -> dict[int, dict[str, Any]]:
1225
+ """Reduce ``entries`` to ``{issue_number: latest_entry}``.
1226
+
1227
+ Sort key is the entry's ``timestamp`` field. ISO-8601 ``Z``-suffixed
1228
+ timestamps sort lexicographically in chronological order; mirrors
1229
+ :func:`candidates_log.latest_decision` for the per-issue case.
1230
+ """
1231
+ out: dict[int, dict[str, Any]] = {}
1232
+ for entry in entries:
1233
+ n = entry.get("issue_number")
1234
+ if not isinstance(n, int):
1235
+ continue
1236
+ cur = out.get(n)
1237
+ if cur is None or entry.get("timestamp", "") > cur.get("timestamp", ""):
1238
+ out[n] = entry
1239
+ return out
1240
+
1241
+
1242
+ # ---------------------------------------------------------------------------
1243
+ # Build queue
1244
+ # ---------------------------------------------------------------------------
1245
+
1246
+
1247
+ def _date_sort_key(issue: dict[str, Any]) -> tuple[int, str]:
1248
+ """Return ``(date_bucket, date_value)`` for the within-group date tiebreak.
1249
+
1250
+ A non-empty ``created_at`` sorts ascending (oldest first) in bucket 0
1251
+ -- the #1419 Slice 1 (#987) creation-date tiebreaker the rank ordering
1252
+ falls back to. When no ``created_at`` is present (a synthetic fixture
1253
+ or a pre-creation-field cache entry) the legacy ``updated_at``-
1254
+ descending order is preserved in bucket 1 so the #1128 within-group
1255
+ behaviour is unchanged. An empty ``updated_at`` maps to ``chr(0)`` so
1256
+ it tail-sorts; a non-empty stamp is character-wise complemented so a
1257
+ more-recent timestamp sorts earlier.
1258
+ """
1259
+ created_at = issue.get("created_at") or ""
1260
+ if created_at:
1261
+ return (0, created_at)
1262
+ updated_at = issue.get("updated_at") or ""
1263
+ # ``max(0, ...)`` keeps the complement non-negative so a stray non-ASCII
1264
+ # char in a malformed timestamp (ord > 0x7F) maps to chr(0) instead of
1265
+ # raising ValueError; valid ASCII ISO-8601 stamps are unaffected.
1266
+ inv = chr(0) if not updated_at else "".join(chr(max(0, 0x7F - ord(c))) for c in updated_at)
1267
+ return (1, inv)
1268
+
1269
+
1270
+ def selection_ordering_key(
1271
+ *,
1272
+ label_index: int,
1273
+ is_continuation: bool,
1274
+ continuation_order: str = "",
1275
+ bucket_deficit: float | None = None,
1276
+ rank: int | None = None,
1277
+ date_key: tuple[int, str] = (1, ""),
1278
+ ) -> tuple[int, int, tuple[float, str], int, int, tuple[int, str]]:
1279
+ """Build the canonical RFC #1419 Layer-3 lexicographic selection key.
1280
+
1281
+ The RFC order is ``(urgent/blocking down, continuation down,
1282
+ bucket-deficit down, intra-bucket rank down, date up)``. This helper is
1283
+ the single source of truth for that order so the queue
1284
+ (:func:`_within_group_sort_key`) and the swarm cohort-fill
1285
+ (``swarm_launch.order_cohort``) cannot drift. ``sorted`` is ascending,
1286
+ so every "down" dimension is encoded as a value that is *smaller* for
1287
+ the higher-priority item:
1288
+
1289
+ 1. ``label_index`` -- urgent/blocking: the consumer priority-label rank
1290
+ (lower index = higher priority). Preempts continuation.
1291
+ 2. ``continuation_bucket`` -- ``0`` for continuation work, ``1`` for
1292
+ net-new. Continuation outranks net-new single-issue work.
1293
+ 3. ``secondary`` -- a ``(float, str)`` whose meaning depends on the
1294
+ partition above (the two partitions never interleave because
1295
+ ``continuation_bucket`` already differs): for continuation work it
1296
+ surfaces the OLDEST-started epic first (``continuation_order``
1297
+ ascending, unknown-start last); for net-new work it surfaces the
1298
+ most-under-target bucket first (highest ``bucket_deficit``, negated
1299
+ for ascending sort).
1300
+ 4. ``(rank_bucket, rank_value)`` -- ``plan.metadata.rank``: ranked rows
1301
+ sort ahead of un-ranked ones, lower value first.
1302
+ 5. ``date_key`` -- ``(date_bucket, date_value)`` from
1303
+ :func:`_date_sort_key`: ascending creation date when available.
1304
+ """
1305
+ continuation_bucket = 0 if is_continuation else 1
1306
+ if is_continuation:
1307
+ # Oldest-started epic first: known order keys sort ascending in
1308
+ # bucket 0.0; an unknown start tail-sorts in bucket 1.0.
1309
+ secondary = (0.0, continuation_order) if continuation_order else (1.0, "")
1310
+ elif isinstance(bucket_deficit, int | float) and not isinstance(bucket_deficit, bool):
1311
+ # Most-under-target (highest deficit) first -> negate for ascending.
1312
+ secondary = (-float(bucket_deficit), "")
1313
+ else:
1314
+ secondary = (0.0, "")
1315
+ if isinstance(rank, int) and not isinstance(rank, bool):
1316
+ rank_bucket, rank_value = 0, rank
1317
+ else:
1318
+ rank_bucket, rank_value = 1, 0
1319
+ return (label_index, continuation_bucket, secondary, rank_bucket, rank_value, date_key)
1320
+
1321
+
1322
+ def _within_group_sort_key(
1323
+ issue: dict[str, Any],
1324
+ ranking_labels: tuple[str, ...],
1325
+ ) -> tuple[int, int, tuple[float, str], int, int, tuple[int, str]]:
1326
+ """Return the intra-bucket sort key for a cached-issue row.
1327
+
1328
+ Resolves the five RFC #1419 Layer-3 selection dimensions from the
1329
+ annotations :func:`build_queue` stamps on each issue, then delegates to
1330
+ :func:`selection_ordering_key` (the canonical key shared with swarm
1331
+ cohort-fill):
1332
+
1333
+ 1. ``rank_index`` -- the consumer priority-label rank (#1128).
1334
+ 2. ``_continuation`` / ``_continuation_order`` -- continuation
1335
+ precedence (#1419 Slice 2 / #987): started-epic work first, oldest
1336
+ epic first.
1337
+ 3. ``_bucket_deficit`` -- deficit-biased net-new selection (#1419
1338
+ Slice 2): most-under-target bucket first.
1339
+ 4. ``_resolved_rank`` -- the vBRIEF-canonical intra-bucket rank
1340
+ (#1419 Slice 1 / #987).
1341
+ 5. ``(date_bucket, date_value)`` from :func:`_date_sort_key`.
1342
+ """
1343
+ rank_index = len(ranking_labels)
1344
+ if ranking_labels:
1345
+ labels = issue.get("labels", []) or []
1346
+ for i, candidate in enumerate(ranking_labels):
1347
+ if candidate in labels:
1348
+ rank_index = i
1349
+ break
1350
+ resolved_rank = issue.get("_resolved_rank")
1351
+ rank = (
1352
+ resolved_rank
1353
+ if isinstance(resolved_rank, int) and not isinstance(resolved_rank, bool)
1354
+ else None
1355
+ )
1356
+ return selection_ordering_key(
1357
+ label_index=rank_index,
1358
+ is_continuation=bool(issue.get("_continuation")),
1359
+ continuation_order=str(issue.get("_continuation_order") or ""),
1360
+ bucket_deficit=issue.get("_bucket_deficit"),
1361
+ rank=rank,
1362
+ date_key=_date_sort_key(issue),
1363
+ )
1364
+
1365
+
1366
+ def matched_label_for(
1367
+ issue: dict[str, Any],
1368
+ ranking_labels: tuple[str, ...],
1369
+ ) -> str | None:
1370
+ """Return the first ranking-label the issue matches, or ``None``."""
1371
+ if not ranking_labels:
1372
+ return None
1373
+ labels = issue.get("labels", []) or []
1374
+ for candidate in ranking_labels:
1375
+ if candidate in labels:
1376
+ return candidate
1377
+ return None
1378
+
1379
+
1380
+ def _resolve_rank(
1381
+ issue: dict[str, Any],
1382
+ number: int,
1383
+ rank_by_number: dict[int, int],
1384
+ ) -> int | None:
1385
+ """Resolve a queue row's effective ``plan.metadata.rank`` (#1419 / #987).
1386
+
1387
+ Precedence: an explicit :attr:`QueueBuildOptions.rank_by_number` entry
1388
+ (the programmatic surface) wins; otherwise the ``_metadata_rank``
1389
+ annotation that :func:`load_cached_issues` stamps from the scope
1390
+ vBRIEFs (the CLI surface) is used. Returns ``None`` -- so the row
1391
+ tail-sorts after ranked peers -- when neither supplies an int rank.
1392
+ """
1393
+ candidate = rank_by_number.get(number)
1394
+ if candidate is None:
1395
+ candidate = issue.get("_metadata_rank")
1396
+ if isinstance(candidate, bool) or not isinstance(candidate, int):
1397
+ return None
1398
+ return candidate
1399
+
1400
+
1401
+ def _resolve_continuation(
1402
+ issue: dict[str, Any],
1403
+ number: int,
1404
+ continuation_numbers: frozenset[int] | set[int],
1405
+ ) -> bool:
1406
+ """Resolve whether a queue row is continuation work (#1419 Slice 2 / #987).
1407
+
1408
+ Precedence mirrors :func:`_resolve_rank`: an explicit
1409
+ :attr:`QueueBuildOptions.continuation_numbers` membership (programmatic
1410
+ surface) wins; otherwise the ``_continuation`` annotation that
1411
+ :func:`load_cached_issues` stamps from the scope vBRIEFs (CLI surface)
1412
+ is used.
1413
+ """
1414
+ if number in continuation_numbers:
1415
+ return True
1416
+ return bool(issue.get("_continuation"))
1417
+
1418
+
1419
+ def _resolve_continuation_order(
1420
+ issue: dict[str, Any],
1421
+ number: int,
1422
+ order_by_number: Mapping[int, str],
1423
+ ) -> str:
1424
+ """Resolve a continuation row's "oldest-started epic" ordering key.
1425
+
1426
+ The programmatic ``continuation_order_by_number`` entry wins; otherwise
1427
+ the ``_continuation_order`` annotation stamped by
1428
+ :func:`load_cached_issues` is used. Returns ``""`` (unknown -- tail
1429
+ sorts among continuation work) when neither supplies a string.
1430
+ """
1431
+ candidate = order_by_number.get(number)
1432
+ if candidate is None:
1433
+ candidate = issue.get("_continuation_order")
1434
+ return candidate if isinstance(candidate, str) else ""
1435
+
1436
+
1437
+ def _resolve_deficit(
1438
+ issue: dict[str, Any],
1439
+ number: int,
1440
+ deficit_by_number: Mapping[int, float],
1441
+ ) -> float | None:
1442
+ """Resolve a queue row's capacity-bucket deficit (#1419 Slice 2).
1443
+
1444
+ The programmatic ``deficit_by_number`` entry wins; otherwise the
1445
+ ``_bucket_deficit`` annotation stamped by :func:`load_cached_issues`
1446
+ from the Slice-4 accounting engine is used. Returns ``None`` (no
1447
+ deficit signal -- neutral among net-new peers) when neither supplies a
1448
+ real number.
1449
+ """
1450
+ candidate = deficit_by_number.get(number)
1451
+ if candidate is None:
1452
+ candidate = issue.get("_bucket_deficit")
1453
+ if isinstance(candidate, bool) or not isinstance(candidate, int | float):
1454
+ return None
1455
+ return float(candidate)
1456
+
1457
+
1458
+ def _resolve_blocked(
1459
+ issue: dict[str, Any],
1460
+ number: int,
1461
+ blocked_numbers: frozenset[int] | set[int],
1462
+ ) -> bool:
1463
+ """Resolve whether a queue row is blocked (#1286).
1464
+
1465
+ Precedence mirrors :func:`_resolve_continuation`: an explicit
1466
+ :attr:`QueueBuildOptions.blocked_issue_numbers` membership (the
1467
+ programmatic surface) wins; otherwise the ``_blocked`` annotation that
1468
+ :func:`load_cached_issues` stamps from the in-flight scope vBRIEFs (the
1469
+ CLI surface) is used.
1470
+ """
1471
+ if number in blocked_numbers:
1472
+ return True
1473
+ return bool(issue.get("_blocked"))
1474
+
1475
+
1476
+ def build_queue(
1477
+ issues: Iterable[dict[str, Any]],
1478
+ audit_entries: Iterable[dict[str, Any]],
1479
+ *,
1480
+ repo: str,
1481
+ options: QueueBuildOptions | None = None,
1482
+ ) -> list[QueueItem]:
1483
+ """Compose the ranked queue.
1484
+
1485
+ ``issues`` and ``audit_entries`` are typically produced by
1486
+ :func:`load_cached_issues` and :func:`read_audit_entries` but tests
1487
+ can pass synthetic fixtures directly.
1488
+ """
1489
+ opts = options or QueueBuildOptions()
1490
+ issue_list = list(issues)
1491
+ decisions = latest_decisions_by_issue(audit_entries)
1492
+ rank_by_number = dict(opts.rank_by_number)
1493
+ # finishBeforeStart (#1419 Slice 2): at/near wipCap only continuation
1494
+ # work is promotable, so net-new scopes are dropped from the queue.
1495
+ drop_net_new = opts.finish_before_start and opts.wip_at_cap
1496
+
1497
+ grouped: dict[str, list[dict[str, Any]]] = {g: [] for g in GROUP_ORDER}
1498
+ for issue in issue_list:
1499
+ n = issue.get("number")
1500
+ if not isinstance(n, int):
1501
+ continue
1502
+ is_continuation = _resolve_continuation(issue, n, opts.continuation_numbers)
1503
+ is_orphan = n in opts.orphan_issue_numbers
1504
+ # finishBeforeStart drops NET-NEW work only. ORPHAN items (D13 /
1505
+ # #1132 -- committed work the framework risks losing) and
1506
+ # continuation work survive, so the policy never hides an orphan the
1507
+ # operator must still see.
1508
+ if drop_net_new and not is_continuation and not is_orphan:
1509
+ continue
1510
+ is_blocked = _resolve_blocked(issue, n, opts.blocked_issue_numbers)
1511
+ latest = decisions.get(n)
1512
+ latest_decision = latest.get("decision") if isinstance(latest, dict) else None
1513
+ # #1286: a blocked item (status:blocked / unresolved depends_on) is
1514
+ # demoted into the BLOCKED group (bottom of GROUP_ORDER) by default
1515
+ # so the queue surfaces only grabbable work. The --include-blocked
1516
+ # opt-in (opts.include_blocked) re-surfaces it into its natural
1517
+ # group instead, so the demotion check runs FIRST.
1518
+ if is_blocked and not opts.include_blocked:
1519
+ group = "BLOCKED"
1520
+ # D13 (#1132): ORPHAN takes precedence over every other group --
1521
+ # an orphan is work the framework already committed to and risks
1522
+ # losing, so it surfaces above RESUME / URGENT / untriaged.
1523
+ elif is_orphan:
1524
+ group = "ORPHAN"
1525
+ else:
1526
+ group = derive_group(latest_decision, n in opts.active_referenced)
1527
+ issue["_latest_decision"] = latest_decision
1528
+ issue["_blocked"] = is_blocked
1529
+ issue["_resolved_rank"] = _resolve_rank(issue, n, rank_by_number)
1530
+ issue["_continuation"] = is_continuation
1531
+ issue["_continuation_order"] = _resolve_continuation_order(
1532
+ issue, n, opts.continuation_order_by_number
1533
+ )
1534
+ issue["_bucket_deficit"] = _resolve_deficit(issue, n, opts.deficit_by_number)
1535
+ grouped[group].append(issue)
1536
+
1537
+ out: list[QueueItem] = []
1538
+ for group in GROUP_ORDER:
1539
+ bucket = sorted(
1540
+ grouped[group],
1541
+ key=lambda i: _within_group_sort_key(i, opts.ranking_labels),
1542
+ )
1543
+ for issue in bucket:
1544
+ out.append(
1545
+ QueueItem(
1546
+ number=int(issue["number"]),
1547
+ title=str(issue.get("title", "")),
1548
+ state=str(issue.get("state", "open")),
1549
+ labels=tuple(issue.get("labels", []) or []),
1550
+ updated_at=str(issue.get("updated_at", "")),
1551
+ group=group,
1552
+ latest_decision=issue.get("_latest_decision"),
1553
+ matched_label=matched_label_for(issue, opts.ranking_labels),
1554
+ repo=repo,
1555
+ )
1556
+ )
1557
+ if opts.limit is not None and len(out) >= opts.limit:
1558
+ return out
1559
+ return out
1560
+
1561
+
1562
+ # ---------------------------------------------------------------------------
1563
+ # Audit date / action filters (#1180 -- lightweight triage metrics)
1564
+ # ---------------------------------------------------------------------------
1565
+
1566
+
1567
+ #: Decision verbs accepted by ``--action=<verb>``.
1568
+ #:
1569
+ #: Sourced from :mod:`candidates_log`'s frozen vocabulary when the module
1570
+ #: is importable; falls back to the literal set so a slim test checkout
1571
+ #: still gets a useful error message.
1572
+ _AUDIT_ACTION_FALLBACK: frozenset[str] = frozenset(
1573
+ {
1574
+ "accept",
1575
+ "reject",
1576
+ "defer",
1577
+ "needs-ac",
1578
+ "mark-duplicate",
1579
+ "reset",
1580
+ "resume-eligible",
1581
+ }
1582
+ )
1583
+
1584
+
1585
+ def valid_audit_actions() -> frozenset[str]:
1586
+ """Return the valid set of ``--action=<verb>`` values.
1587
+
1588
+ Prefers :data:`candidates_log._VALID_DECISIONS` (the canonical
1589
+ vocabulary frozen by ``vbrief/schemas/candidates.schema.json``);
1590
+ falls back to a private mirror so the CLI still surfaces a useful
1591
+ error message on a checkout where the audit-log module has not been
1592
+ imported yet.
1593
+ """
1594
+ if candidates_log is not None:
1595
+ decisions = getattr(candidates_log, "_VALID_DECISIONS", None)
1596
+ if isinstance(decisions, frozenset):
1597
+ return decisions
1598
+ return _AUDIT_ACTION_FALLBACK
1599
+
1600
+
1601
+ def parse_audit_window(raw: str) -> timedelta:
1602
+ """Parse a ``--since=<window>`` duration into a :class:`timedelta`.
1603
+
1604
+ Delegates to :func:`triage_scope.parse_duration` when importable so
1605
+ the framework keeps a single duration grammar across D12 / #1131 +
1606
+ #1180. Falls back to an inline ``N(s|m|h|d|w)`` parser for slim test
1607
+ checkouts that have not rebased onto D12 yet.
1608
+
1609
+ Raises :class:`ValueError` on malformed input; the error message is
1610
+ suitable for direct surfacing to stderr by the CLI shim.
1611
+ """
1612
+ if triage_scope is not None and hasattr(triage_scope, "parse_duration"):
1613
+ return triage_scope.parse_duration(raw) # type: ignore[no-any-return]
1614
+ # Slim-checkout fallback. Mirrors the compact form documented by
1615
+ # triage_scope.parse_duration so the grammar stays consistent.
1616
+ if not isinstance(raw, str):
1617
+ raise ValueError(f"duration must be a string, got {type(raw).__name__}")
1618
+ text = raw.strip()
1619
+ if not text:
1620
+ raise ValueError("duration must be a non-empty string")
1621
+ if len(text) < 2 or not text[:-1].isdigit():
1622
+ raise ValueError(f"invalid duration {raw!r}: expected '<N>(s|m|h|d|w)' (e.g. '7d', '24h')")
1623
+ n = int(text[:-1])
1624
+ unit = text[-1].lower()
1625
+ if unit == "s":
1626
+ return timedelta(seconds=n)
1627
+ if unit == "m":
1628
+ return timedelta(minutes=n)
1629
+ if unit == "h":
1630
+ return timedelta(hours=n)
1631
+ if unit == "d":
1632
+ return timedelta(days=n)
1633
+ if unit == "w":
1634
+ return timedelta(weeks=n)
1635
+ raise ValueError(f"invalid duration {raw!r}: expected '<N>(s|m|h|d|w)' (e.g. '7d', '24h')")
1636
+
1637
+
1638
+ def filter_by_since(
1639
+ entries: Iterable[dict[str, Any]],
1640
+ window: timedelta,
1641
+ *,
1642
+ now: datetime | None = None,
1643
+ ) -> list[dict[str, Any]]:
1644
+ """Return entries whose ``timestamp`` is at-or-after ``now - window``.
1645
+
1646
+ Entries with a missing / malformed timestamp are dropped (they cannot
1647
+ be placed on the time axis). ``window`` is interpreted inclusively
1648
+ (``ts >= cutoff``) so ``--since=0s`` returns every still-valid entry.
1649
+ """
1650
+ cutoff = (now or _utc_now()) - window
1651
+ out: list[dict[str, Any]] = []
1652
+ for entry in entries:
1653
+ if not isinstance(entry, dict):
1654
+ continue
1655
+ stamp = entry.get("timestamp")
1656
+ if not isinstance(stamp, str) or not stamp:
1657
+ continue
1658
+ try:
1659
+ text = stamp
1660
+ if text.endswith("Z"):
1661
+ text = text[:-1] + "+00:00"
1662
+ ts = datetime.fromisoformat(text)
1663
+ except ValueError:
1664
+ continue
1665
+ if ts.tzinfo is None:
1666
+ ts = ts.replace(tzinfo=UTC)
1667
+ if ts >= cutoff:
1668
+ out.append(entry)
1669
+ return out
1670
+
1671
+
1672
+ def filter_by_action(
1673
+ entries: Iterable[dict[str, Any]],
1674
+ action: str,
1675
+ ) -> list[dict[str, Any]]:
1676
+ """Return entries whose ``decision`` equals ``action``.
1677
+
1678
+ The caller is responsible for validating ``action`` against
1679
+ :func:`valid_audit_actions` before invoking this helper -- a typo
1680
+ here would silently return an empty list, which is the wrong UX for
1681
+ a CLI flag. Validation lives in the argparse shim.
1682
+ """
1683
+ return [e for e in entries if isinstance(e, dict) and e.get("decision") == action]
1684
+
1685
+
1686
+ # ---------------------------------------------------------------------------
1687
+ # vBRIEF-staleness predicate (used by --vbrief-staleness on audit)
1688
+ # ---------------------------------------------------------------------------
1689
+
1690
+
1691
+ def is_stale_acceptance(
1692
+ entry: dict[str, Any],
1693
+ active_referenced: frozenset[int] | set[int],
1694
+ ) -> bool:
1695
+ """Return True if ``entry`` is an ``accept`` decision whose issue is no
1696
+ longer referenced by any ``vbrief/active/`` plan.
1697
+
1698
+ The framework treats "stale acceptance" as the load-bearing failure
1699
+ mode for D4's cap-reached error message (#1124): an accepted issue
1700
+ that has no active vBRIEF is one of two things, both of which the
1701
+ operator should see:
1702
+
1703
+ * the operator accepted but never authored an active vBRIEF (the
1704
+ ingest never landed), OR
1705
+ * the vBRIEF lifecycle moved (completed / cancelled) without the
1706
+ audit log being reset back to a terminal state.
1707
+ """
1708
+ if not isinstance(entry, dict):
1709
+ return False
1710
+ if entry.get("decision") != "accept":
1711
+ return False
1712
+ n = entry.get("issue_number")
1713
+ if not isinstance(n, int):
1714
+ return False
1715
+ return n not in active_referenced
1716
+
1717
+
1718
+ # ---------------------------------------------------------------------------
1719
+ # Renderers
1720
+ # ---------------------------------------------------------------------------
1721
+
1722
+
1723
+ def _truncate(text: str, width: int) -> str:
1724
+ if width <= 1 or len(text) <= width:
1725
+ return text
1726
+ return text[: width - 1] + "..."
1727
+
1728
+
1729
+ def render_queue(
1730
+ items: Iterable[QueueItem],
1731
+ *,
1732
+ repo: str,
1733
+ limit: int | None = None,
1734
+ ranking_labels: tuple[str, ...] = (),
1735
+ ) -> str:
1736
+ """Pretty-print the ranked queue.
1737
+
1738
+ Header line names the repo + (when applicable) the consumer ranking
1739
+ labels in declared order so an operator reading the output can tell
1740
+ at a glance whether the framework default or consumer config is in
1741
+ force.
1742
+ """
1743
+ rows = list(items)
1744
+ lines: list[str] = []
1745
+ lines.append(f"triage:queue -- {repo}")
1746
+ if ranking_labels:
1747
+ lines.append(" consumer ranking labels (in declared order): " + ", ".join(ranking_labels))
1748
+ else:
1749
+ lines.append(
1750
+ " consumer ranking labels: <empty> (framework default; within-group = updated_at desc)"
1751
+ )
1752
+ if limit is not None:
1753
+ lines.append(f" limit: {limit}")
1754
+ lines.append("")
1755
+ if not rows:
1756
+ lines.append(" (no cached issues -- run `task triage:bootstrap` first)")
1757
+ return "\n".join(lines)
1758
+ for item in rows:
1759
+ marker = GROUP_DISPLAY.get(item.group, f"[{item.group}] ")
1760
+ label_hint = ""
1761
+ if item.matched_label:
1762
+ label_hint = f" (label: {item.matched_label})"
1763
+ title = _truncate(item.title, 72)
1764
+ lines.append(f" {marker}#{item.number} {title} -- updated {item.updated_at}{label_hint}")
1765
+ return "\n".join(lines)
1766
+
1767
+
1768
+ def render_show(
1769
+ issue: dict[str, Any] | None,
1770
+ *,
1771
+ repo: str,
1772
+ number: int,
1773
+ latest_decision: dict[str, Any] | None,
1774
+ history: list[dict[str, Any]],
1775
+ in_active_vbrief: bool,
1776
+ ) -> str:
1777
+ """Pretty-print one issue + its triage state."""
1778
+ lines: list[str] = []
1779
+ lines.append(f"triage:show -- {repo}#{number}")
1780
+ if issue is None:
1781
+ lines.append("")
1782
+ lines.append(" (issue not present in local cache)")
1783
+ lines.append(" Run `task triage:bootstrap` to populate, or check the repo slug.")
1784
+ return "\n".join(lines)
1785
+ title = issue.get("title", "")
1786
+ state = issue.get("state", "open")
1787
+ labels = issue.get("labels", []) or []
1788
+ updated_at = issue.get("updated_at", "")
1789
+ lines.append(f" title: {title}")
1790
+ lines.append(f" state: {state}")
1791
+ lines.append(f" labels: {', '.join(labels) if labels else '<none>'}")
1792
+ lines.append(f" updated_at: {updated_at}")
1793
+ lines.append("")
1794
+ lines.append(f" active vBRIEF reference: {'yes' if in_active_vbrief else 'no'}")
1795
+ if latest_decision:
1796
+ lines.append(
1797
+ " latest decision: "
1798
+ f"{latest_decision.get('decision')} "
1799
+ f"at {latest_decision.get('timestamp')} "
1800
+ f"by {latest_decision.get('actor')}"
1801
+ )
1802
+ reason = latest_decision.get("reason")
1803
+ if reason:
1804
+ lines.append(f" reason: {reason}")
1805
+ else:
1806
+ lines.append(" latest decision: <none -- untriaged>")
1807
+ if history:
1808
+ lines.append("")
1809
+ lines.append(f" history ({len(history)} entries, oldest first):")
1810
+ for entry in history:
1811
+ lines.append(
1812
+ f" - {entry.get('timestamp')} "
1813
+ f"{entry.get('decision'):<14} "
1814
+ f"by {entry.get('actor')}"
1815
+ )
1816
+ return "\n".join(lines)
1817
+
1818
+
1819
+ def render_audit_plain(
1820
+ entries: list[dict[str, Any]],
1821
+ *,
1822
+ repo: str | None,
1823
+ vbrief_staleness: bool,
1824
+ ) -> str:
1825
+ """Plain-text audit-log dump consumed by humans."""
1826
+ lines: list[str] = []
1827
+ header = "triage:audit"
1828
+ if repo:
1829
+ header += f" -- {repo}"
1830
+ if vbrief_staleness:
1831
+ header += " [--vbrief-staleness: accepted issues without active vBRIEF]"
1832
+ lines.append(header)
1833
+ lines.append("")
1834
+ if not entries:
1835
+ lines.append(" (no matching audit entries)")
1836
+ return "\n".join(lines)
1837
+ for entry in entries:
1838
+ lines.append(
1839
+ f" {entry.get('timestamp')} "
1840
+ f"{(entry.get('decision') or '?'): <14} "
1841
+ f"#{entry.get('issue_number')} "
1842
+ f"by {entry.get('actor', '?')}"
1843
+ )
1844
+ reason = entry.get("reason")
1845
+ if reason:
1846
+ lines.append(f" reason: {reason}")
1847
+ return "\n".join(lines)
1848
+
1849
+
1850
+ def render_audit_json(
1851
+ entries: list[dict[str, Any]],
1852
+ *,
1853
+ repo: str | None,
1854
+ vbrief_staleness: bool,
1855
+ generated_at: datetime | None = None,
1856
+ ) -> str:
1857
+ """Stable-schema JSON audit dump consumed by D2 (#1122) / D4 (#1124).
1858
+
1859
+ The schema is the dict::
1860
+
1861
+ {
1862
+ "generated_at": "<ISO-8601 UTC, Z-suffixed>",
1863
+ "repo": "<owner/name>" | null,
1864
+ "vbrief_staleness": <bool>,
1865
+ "entry_count": <int>,
1866
+ "entries": [
1867
+ {... candidates_log entry passthrough ...},
1868
+ ...
1869
+ ]
1870
+ }
1871
+
1872
+ The ``entries`` array is verbatim ``candidates_log`` records; we do
1873
+ not reshape them so downstream consumers can rely on
1874
+ ``vbrief/schemas/candidates.schema.json`` as the per-row contract.
1875
+ """
1876
+ payload = {
1877
+ "generated_at": _utc_iso(generated_at),
1878
+ "repo": repo,
1879
+ "vbrief_staleness": bool(vbrief_staleness),
1880
+ "entry_count": len(entries),
1881
+ "entries": list(entries),
1882
+ }
1883
+ return json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
1884
+
1885
+
1886
+ # ---------------------------------------------------------------------------
1887
+ # Active-vBRIEF reference set
1888
+ # ---------------------------------------------------------------------------
1889
+
1890
+
1891
+ def _active_referenced_issue_numbers(project_root: Path | None) -> set[int]:
1892
+ """Return issue numbers referenced by any ``vbrief/active/*.vbrief.json``.
1893
+
1894
+ Delegates to ``triage_scope.extract_referenced_issues`` when the
1895
+ upstream D12 module is importable (the canonical reader); falls back
1896
+ to a small inline reader so this module remains usable on checkouts
1897
+ that have not yet rebased onto D12 (#1131).
1898
+ """
1899
+ if triage_scope is not None and hasattr(triage_scope, "extract_referenced_issues"):
1900
+ refs = triage_scope.extract_referenced_issues(project_root)
1901
+ active = refs.get("active") if isinstance(refs, dict) else None
1902
+ if isinstance(active, set):
1903
+ return set(active)
1904
+ root = (project_root or Path.cwd()) / "vbrief" / "active"
1905
+ if not root.is_dir():
1906
+ return set()
1907
+ out: set[int] = set()
1908
+ for path in root.glob("*.vbrief.json"):
1909
+ try:
1910
+ data = json.loads(path.read_text(encoding="utf-8"))
1911
+ except (json.JSONDecodeError, OSError):
1912
+ continue
1913
+ plan = data.get("plan") if isinstance(data, dict) else None
1914
+ if not isinstance(plan, dict):
1915
+ continue
1916
+ out |= _issue_numbers_from_plan(plan)
1917
+ return out
1918
+
1919
+
1920
+ # ---------------------------------------------------------------------------
1921
+ # CLI entry point. Argparse + subcommand dispatch live in
1922
+ # ``scripts/_triage_queue_cli.py`` so this module stays under the
1923
+ # 1000-line MUST cap documented in ``coding/coding.md``.
1924
+ # ---------------------------------------------------------------------------
1925
+
1926
+
1927
+ def main(argv: list[str] | None = None) -> int:
1928
+ """CLI entry point. Delegates to :mod:`_triage_queue_cli`."""
1929
+ import sys as _sys
1930
+
1931
+ # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
1932
+ from triage_help import intercept_help
1933
+
1934
+ rc = intercept_help("triage_queue", argv)
1935
+ if rc is not None:
1936
+ return rc
1937
+
1938
+ from _triage_queue_cli import run_cli # local import: 1000-line cap
1939
+
1940
+ return run_cli(argv, _sys.modules[__name__])
1941
+
1942
+
1943
+ if __name__ == "__main__":
1944
+ sys.exit(main())