@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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- 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())
|