@deftai/directive-content 0.55.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- 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/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 +1 -1
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""preflight_cache.py -- detection-bound cache-freshness gate (#1127, D5 of #1119).
|
|
3
|
+
|
|
4
|
+
Pure stdlib, cross-platform. Invoked from:
|
|
5
|
+
|
|
6
|
+
- ``deft verify:cache-fresh`` (aggregated into ``task check``)
|
|
7
|
+
- Dispatcher pre-``start_agent`` invocations -- the dispatcher MUST run
|
|
8
|
+
``deft verify:cache-fresh --for-issue <N>`` before any ``start_agent``
|
|
9
|
+
and refuse dispatch on any non-zero exit (see
|
|
10
|
+
``templates/agent-prompt-preamble.md`` § 12).
|
|
11
|
+
|
|
12
|
+
Mirrors ``scripts/preflight_branch.py`` (#747) in shape: pure-stdlib so it
|
|
13
|
+
can run from a fresh git hook or a minimal CI runner before ``uv sync``
|
|
14
|
+
has produced an environment.
|
|
15
|
+
|
|
16
|
+
Exit codes (three-state):
|
|
17
|
+
|
|
18
|
+
- ``0`` -- cache fresh AND no blocking defer conditions.
|
|
19
|
+
- ``1`` -- cache stale OR blocking conditions found; prints remediation
|
|
20
|
+
to stderr (names ``deft triage:bootstrap`` and ``deft cache:fetch-all``
|
|
21
|
+
per the issue body).
|
|
22
|
+
- ``2`` -- config error: ``.deft-cache/`` missing entirely, or
|
|
23
|
+
``vbrief/.eval/candidates.jsonl`` missing. The config-error class is
|
|
24
|
+
distinct from "cache stale" so a dispatcher can distinguish a never-
|
|
25
|
+
-bootstrapped project (operator runs ``deft triage:bootstrap``) from
|
|
26
|
+
a stale-cache project (operator runs ``deft cache:fetch-all``).
|
|
27
|
+
|
|
28
|
+
State machine (#1240):
|
|
29
|
+
|
|
30
|
+
Three user-visible states the OK message must distinguish post-#1240
|
|
31
|
+
because ``deft triage:bootstrap`` now seeds an empty audit log:
|
|
32
|
+
|
|
33
|
+
1. **No cache yet** -- ``.deft-cache/<source>/`` absent. This is the
|
|
34
|
+
never-bootstrapped state; the gate exits 2 (or 0 + bootstrap-state
|
|
35
|
+
message when ``--allow-missing-bootstrap`` is passed).
|
|
36
|
+
2. **Cache present + audit log empty** -- consumer just ran
|
|
37
|
+
``deft triage:bootstrap`` but has not yet executed any triage
|
|
38
|
+
action. The gate exits 0 with a ``fresh bootstrap, no triage
|
|
39
|
+
actions yet`` message. Pre-#1240 this state was unreachable because
|
|
40
|
+
bootstrap left the audit log absent -- the gate fell through to
|
|
41
|
+
the config-error branch and printed ``treating as bootstrap state``
|
|
42
|
+
on a freshly-bootstrapped consumer.
|
|
43
|
+
3. **Cache present + audit log non-empty** -- canonical fresh state.
|
|
44
|
+
The gate exits 0 with the ``actively triaging`` message.
|
|
45
|
+
|
|
46
|
+
Subscription-awareness (#1131 / D12 of #1119):
|
|
47
|
+
|
|
48
|
+
The freshness check is scoped to the consumer's
|
|
49
|
+
``plan.policy.triageScope[]`` subscription -- read via the D12 surface
|
|
50
|
+
:mod:`triage_scope` -- so a consumer with a tightened scope is not
|
|
51
|
+
gated by stale entries the operator has explicitly chosen not to track.
|
|
52
|
+
When ``--for-issue <N>`` is given the gate ALSO verifies the issue is
|
|
53
|
+
in scope; an out-of-scope issue exits 1 with a pointer to
|
|
54
|
+
``deft triage:scope``.
|
|
55
|
+
|
|
56
|
+
Override paths:
|
|
57
|
+
|
|
58
|
+
- ``--allow-stale`` -- exit 0 with an audit-trail warning on stderr.
|
|
59
|
+
Per-shell only; never persisted. Same shape as
|
|
60
|
+
``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT`` from #747.
|
|
61
|
+
- ``--max-age-hours N`` / ``DEFT_CACHE_MAX_AGE_HOURS=N`` -- override the
|
|
62
|
+
default 24h freshness window (env honoured when the flag is absent).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import argparse
|
|
68
|
+
import contextlib
|
|
69
|
+
import json
|
|
70
|
+
import os
|
|
71
|
+
import subprocess
|
|
72
|
+
import sys
|
|
73
|
+
from collections.abc import Iterable
|
|
74
|
+
from dataclasses import dataclass
|
|
75
|
+
from datetime import UTC, datetime, timedelta
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Any
|
|
78
|
+
|
|
79
|
+
# Make sibling ``scripts`` modules importable when invoked via
|
|
80
|
+
# ``python scripts/preflight_cache.py`` from any working directory.
|
|
81
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
82
|
+
|
|
83
|
+
# UTF-8 self-reconfigure (#814) -- error / OK messages include the ✓ /
|
|
84
|
+
# ⚠ / ❌ glyphs that cp1252 cannot encode.
|
|
85
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
86
|
+
if hasattr(_stream, "reconfigure"):
|
|
87
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
88
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Public constants
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
#: Cache directory name (mirrors ``scripts/cache.py::DEFAULT_CACHE_ROOT``).
|
|
96
|
+
CACHE_DIR_NAME: str = ".deft-cache"
|
|
97
|
+
|
|
98
|
+
#: Source the gate inspects. v1 ships ``github-issue`` only -- the same
|
|
99
|
+
#: scoping decision documented in #1127 "Not in scope".
|
|
100
|
+
DEFAULT_SOURCE: str = "github-issue"
|
|
101
|
+
|
|
102
|
+
#: Candidates audit log (mirrors ``scripts/candidates_log.py::DEFAULT_LOG_PATH``).
|
|
103
|
+
CANDIDATES_RELPATH: Path = Path("vbrief") / ".eval" / "candidates.jsonl"
|
|
104
|
+
|
|
105
|
+
#: Default freshness window in hours; configurable via flag / env.
|
|
106
|
+
DEFAULT_MAX_AGE_HOURS: int = 24
|
|
107
|
+
|
|
108
|
+
#: Env var override for the freshness window (parsed as int hours).
|
|
109
|
+
ENV_MAX_AGE_HOURS: str = "DEFT_CACHE_MAX_AGE_HOURS"
|
|
110
|
+
|
|
111
|
+
#: Env var honoured for repo inference when --repo is absent (mirrors
|
|
112
|
+
#: ``scripts/triage_bootstrap.py::DEFT_TRIAGE_REPO``).
|
|
113
|
+
ENV_TRIAGE_REPO: str = "DEFT_TRIAGE_REPO"
|
|
114
|
+
|
|
115
|
+
#: Decision verdict required for ``--for-issue`` to clear the gate. Any
|
|
116
|
+
#: other latest decision (``defer`` / ``reject`` / ``needs-ac`` /
|
|
117
|
+
#: ``mark-duplicate`` / ``reset``) blocks dispatch.
|
|
118
|
+
REQUIRED_DECISION: str = "accept"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Result dataclass + helpers
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class GateResult:
|
|
128
|
+
"""Pure-data result of :func:`evaluate`. ``code`` is the exit code."""
|
|
129
|
+
|
|
130
|
+
code: int
|
|
131
|
+
message: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _utc_now() -> datetime:
|
|
135
|
+
return datetime.now(UTC)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_iso(stamp: str) -> datetime:
|
|
139
|
+
"""Parse an ISO-8601 timestamp; accepts trailing ``Z``."""
|
|
140
|
+
text = stamp.strip()
|
|
141
|
+
if text.endswith("Z"):
|
|
142
|
+
text = text[:-1] + "+00:00"
|
|
143
|
+
dt = datetime.fromisoformat(text)
|
|
144
|
+
if dt.tzinfo is None:
|
|
145
|
+
dt = dt.replace(tzinfo=UTC)
|
|
146
|
+
return dt
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Repo discovery
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _infer_repo_from_git(project_root: Path) -> str | None:
|
|
155
|
+
"""Best-effort: read ``git remote get-url origin`` inside ``project_root``.
|
|
156
|
+
|
|
157
|
+
Returns ``"owner/name"`` on success, ``None`` otherwise. A stuck git
|
|
158
|
+
proxy (corporate VPN re-auth) is bounded by a 10s timeout so the
|
|
159
|
+
gate never hangs the dispatcher.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
proc = subprocess.run(
|
|
163
|
+
["git", "remote", "get-url", "origin"],
|
|
164
|
+
cwd=str(project_root),
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
check=False,
|
|
168
|
+
timeout=10,
|
|
169
|
+
)
|
|
170
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
171
|
+
return None
|
|
172
|
+
if proc.returncode != 0:
|
|
173
|
+
return None
|
|
174
|
+
url = proc.stdout.strip()
|
|
175
|
+
if not url:
|
|
176
|
+
return None
|
|
177
|
+
# github.com/owner/name(.git) -- accepts ssh / https / git protocol.
|
|
178
|
+
cleaned = url.rstrip("/")
|
|
179
|
+
if cleaned.endswith(".git"):
|
|
180
|
+
cleaned = cleaned[: -len(".git")]
|
|
181
|
+
if "github.com" not in cleaned:
|
|
182
|
+
return None
|
|
183
|
+
tail = cleaned.split("github.com", 1)[1].lstrip(":/")
|
|
184
|
+
parts = tail.split("/")
|
|
185
|
+
if len(parts) >= 2 and parts[0] and parts[1]:
|
|
186
|
+
return f"{parts[0]}/{parts[1]}"
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _scan_cache_for_single_repo(cache_root: Path, source: str) -> str | None:
|
|
191
|
+
"""Return ``owner/name`` when the cache contains exactly one repo, else None."""
|
|
192
|
+
base = cache_root / source
|
|
193
|
+
if not base.is_dir():
|
|
194
|
+
return None
|
|
195
|
+
pairs: list[tuple[str, str]] = []
|
|
196
|
+
for owner_dir in sorted(base.iterdir()):
|
|
197
|
+
if not owner_dir.is_dir():
|
|
198
|
+
continue
|
|
199
|
+
for repo_dir in sorted(owner_dir.iterdir()):
|
|
200
|
+
if repo_dir.is_dir():
|
|
201
|
+
pairs.append((owner_dir.name, repo_dir.name))
|
|
202
|
+
if len(pairs) == 1:
|
|
203
|
+
owner, name = pairs[0]
|
|
204
|
+
return f"{owner}/{name}"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _resolve_repo(
|
|
209
|
+
project_root: Path,
|
|
210
|
+
cache_root: Path,
|
|
211
|
+
source: str,
|
|
212
|
+
*,
|
|
213
|
+
explicit: str | None,
|
|
214
|
+
) -> str | None:
|
|
215
|
+
"""Resolve the repo slug in priority order: flag > env > git > single-cache-repo."""
|
|
216
|
+
if explicit:
|
|
217
|
+
return explicit
|
|
218
|
+
env_repo = os.environ.get(ENV_TRIAGE_REPO, "").strip()
|
|
219
|
+
if env_repo:
|
|
220
|
+
return env_repo
|
|
221
|
+
inferred = _infer_repo_from_git(project_root)
|
|
222
|
+
if inferred:
|
|
223
|
+
return inferred
|
|
224
|
+
return _scan_cache_for_single_repo(cache_root, source)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Cache scanning
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _iter_meta_paths(cache_root: Path, source: str, repo: str) -> Iterable[Path]:
|
|
233
|
+
"""Yield each ``meta.json`` path under ``<cache_root>/<source>/<repo>/*/``."""
|
|
234
|
+
if "/" not in repo:
|
|
235
|
+
return
|
|
236
|
+
owner, name = repo.split("/", 1)
|
|
237
|
+
repo_dir = cache_root / source / owner / name
|
|
238
|
+
if not repo_dir.is_dir():
|
|
239
|
+
return
|
|
240
|
+
for entry in sorted(repo_dir.iterdir()):
|
|
241
|
+
if not entry.is_dir():
|
|
242
|
+
continue
|
|
243
|
+
meta = entry / "meta.json"
|
|
244
|
+
if meta.is_file():
|
|
245
|
+
yield meta
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _read_meta(meta_path: Path) -> dict[str, Any] | None:
|
|
249
|
+
try:
|
|
250
|
+
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
251
|
+
except (OSError, json.JSONDecodeError):
|
|
252
|
+
return None
|
|
253
|
+
return data if isinstance(data, dict) else None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_raw_issue(meta_path: Path) -> dict[str, Any] | None:
|
|
257
|
+
"""Read the sibling ``raw.json`` and return the parsed payload."""
|
|
258
|
+
raw_path = meta_path.parent / "raw.json"
|
|
259
|
+
if not raw_path.is_file():
|
|
260
|
+
return None
|
|
261
|
+
try:
|
|
262
|
+
data = json.loads(raw_path.read_text(encoding="utf-8"))
|
|
263
|
+
except (OSError, json.JSONDecodeError):
|
|
264
|
+
return None
|
|
265
|
+
return data if isinstance(data, dict) else None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Subscription-aware filtering (D12 / #1131)
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _load_triage_scope_module() -> Any | None:
|
|
274
|
+
"""Lazy-load :mod:`triage_scope`; returns ``None`` if missing.
|
|
275
|
+
|
|
276
|
+
D12 (#1131) is the upstream surface. The gate degrades gracefully to
|
|
277
|
+
"no subscription filter" when the module is absent so a partial
|
|
278
|
+
install / pre-D12 branch still gets the cache-freshness check.
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
import triage_scope # type: ignore[import-not-found]
|
|
282
|
+
|
|
283
|
+
return triage_scope
|
|
284
|
+
except ImportError:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _resolve_scope_rules(project_root: Path) -> list[dict[str, Any]] | None:
|
|
289
|
+
"""Return the effective ``plan.policy.triageScope[]`` rule list.
|
|
290
|
+
|
|
291
|
+
Returns ``None`` when :mod:`triage_scope` is not importable; in that
|
|
292
|
+
case the caller skips subscription filtering.
|
|
293
|
+
"""
|
|
294
|
+
mod = _load_triage_scope_module()
|
|
295
|
+
if mod is None:
|
|
296
|
+
return None
|
|
297
|
+
try:
|
|
298
|
+
rules = mod.resolve_scope_rules(project_root)
|
|
299
|
+
except Exception: # noqa: BLE001 -- defensive, subscription is optional
|
|
300
|
+
return None
|
|
301
|
+
return list(rules) if isinstance(rules, list) else None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _issue_in_scope(
|
|
305
|
+
rules: list[dict[str, Any]] | None,
|
|
306
|
+
issue: dict[str, Any],
|
|
307
|
+
*,
|
|
308
|
+
project_root: Path,
|
|
309
|
+
) -> bool:
|
|
310
|
+
"""True when ``issue`` is matched by ``rules`` (or rules are absent)."""
|
|
311
|
+
if not rules:
|
|
312
|
+
return True
|
|
313
|
+
mod = _load_triage_scope_module()
|
|
314
|
+
if mod is None:
|
|
315
|
+
return True
|
|
316
|
+
try:
|
|
317
|
+
matched = mod.evaluate_rules(rules, [issue])
|
|
318
|
+
except Exception: # noqa: BLE001 -- defensive fallthrough
|
|
319
|
+
return True
|
|
320
|
+
if not isinstance(matched, list):
|
|
321
|
+
return True
|
|
322
|
+
target_number = issue.get("number")
|
|
323
|
+
return any(
|
|
324
|
+
isinstance(m, dict) and m.get("number") == target_number for m in matched
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _filter_scoped_meta_paths(
|
|
329
|
+
meta_paths: list[Path],
|
|
330
|
+
rules: list[dict[str, Any]] | None,
|
|
331
|
+
*,
|
|
332
|
+
open_milestones_fetcher: Any = None,
|
|
333
|
+
) -> list[Path]:
|
|
334
|
+
"""Filter ``meta_paths`` to those whose raw.json matches the scope rules.
|
|
335
|
+
|
|
336
|
+
#1424: evaluate the rule set ONCE over the whole cache rather than
|
|
337
|
+
once per cached entry. The per-issue fan-out used to call
|
|
338
|
+
``evaluate_rules(rules, [issue])`` N times; because
|
|
339
|
+
``evaluate_rules`` builds (and memoizes only within a single call) a
|
|
340
|
+
fresh open-milestones resolver, a ``milestone {is-open: true}`` rule
|
|
341
|
+
re-fetched the upstream snapshot once per issue -- an O(N) network
|
|
342
|
+
fan-out (~92s on a 500-entry cache). Batching collapses that to a
|
|
343
|
+
single ``evaluate_rules`` call (one milestone fetch) with identical
|
|
344
|
+
semantics, mirroring the proven fetch-once shape in
|
|
345
|
+
``triage_scope_drift.compute_drift``.
|
|
346
|
+
|
|
347
|
+
Matched entries are resolved by OBJECT IDENTITY (``id(issue)``), not
|
|
348
|
+
by issue number: ``evaluate_rules`` dedups via
|
|
349
|
+
``matched.setdefault(_issue_number(issue), issue)`` and returns the
|
|
350
|
+
very issue dicts passed in, so number-keying here would risk
|
|
351
|
+
collisions or drop entries whose ``number`` is missing/None.
|
|
352
|
+
|
|
353
|
+
``open_milestones_fetcher`` is forwarded to ``evaluate_rules`` for
|
|
354
|
+
the ``milestone {is-open: true}`` variant; production leaves it
|
|
355
|
+
``None`` (the default ``gh api`` fetcher fires once), tests inject a
|
|
356
|
+
counting closure to assert the at-most-once contract.
|
|
357
|
+
"""
|
|
358
|
+
if not rules:
|
|
359
|
+
return meta_paths
|
|
360
|
+
mod = _load_triage_scope_module()
|
|
361
|
+
if mod is None:
|
|
362
|
+
# Subscription module unavailable -> no filtering (over-include).
|
|
363
|
+
return meta_paths
|
|
364
|
+
|
|
365
|
+
all_issues: list[dict[str, Any]] = []
|
|
366
|
+
issue_id_to_meta: dict[int, Path] = {}
|
|
367
|
+
# Entries whose raw.json is missing or unparseable are kept: the
|
|
368
|
+
# freshness check is the load-bearing signal and we'd rather
|
|
369
|
+
# over-include than mask a stale cache.
|
|
370
|
+
keep: set[Path] = set()
|
|
371
|
+
for meta_path in meta_paths:
|
|
372
|
+
raw = _read_raw_issue(meta_path)
|
|
373
|
+
if raw is None:
|
|
374
|
+
keep.add(meta_path)
|
|
375
|
+
continue
|
|
376
|
+
all_issues.append(raw)
|
|
377
|
+
issue_id_to_meta[id(raw)] = meta_path
|
|
378
|
+
|
|
379
|
+
if all_issues:
|
|
380
|
+
try:
|
|
381
|
+
matched = mod.evaluate_rules(
|
|
382
|
+
rules, all_issues, open_milestones_fetcher=open_milestones_fetcher
|
|
383
|
+
)
|
|
384
|
+
except Exception: # noqa: BLE001 -- defensive: over-include on failure
|
|
385
|
+
return meta_paths
|
|
386
|
+
if not isinstance(matched, list):
|
|
387
|
+
return meta_paths
|
|
388
|
+
for issue in matched:
|
|
389
|
+
meta_path = issue_id_to_meta.get(id(issue))
|
|
390
|
+
if meta_path is not None:
|
|
391
|
+
keep.add(meta_path)
|
|
392
|
+
|
|
393
|
+
# Preserve the original meta_paths ordering.
|
|
394
|
+
return [meta_path for meta_path in meta_paths if meta_path in keep]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# candidates.jsonl helpers
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _candidates_path(project_root: Path) -> Path:
|
|
403
|
+
return project_root / CANDIDATES_RELPATH
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _latest_decision_for_issue(
|
|
407
|
+
candidates: Path, *, repo: str, issue_number: int
|
|
408
|
+
) -> dict[str, Any] | None:
|
|
409
|
+
"""Return the most recent decision dict for ``(repo, issue_number)``.
|
|
410
|
+
|
|
411
|
+
Mirrors :func:`scripts.candidates_log.latest_decision` without taking
|
|
412
|
+
a hard dependency on the module (pure stdlib here so the gate runs
|
|
413
|
+
on a fresh checkout).
|
|
414
|
+
"""
|
|
415
|
+
if not candidates.is_file():
|
|
416
|
+
return None
|
|
417
|
+
rows: list[dict[str, Any]] = []
|
|
418
|
+
try:
|
|
419
|
+
with candidates.open(encoding="utf-8") as fh:
|
|
420
|
+
for raw_line in fh:
|
|
421
|
+
line = raw_line.strip()
|
|
422
|
+
if not line:
|
|
423
|
+
continue
|
|
424
|
+
try:
|
|
425
|
+
obj = json.loads(line)
|
|
426
|
+
except json.JSONDecodeError:
|
|
427
|
+
continue
|
|
428
|
+
if not isinstance(obj, dict):
|
|
429
|
+
continue
|
|
430
|
+
if obj.get("repo") != repo:
|
|
431
|
+
continue
|
|
432
|
+
if obj.get("issue_number") != issue_number:
|
|
433
|
+
continue
|
|
434
|
+
rows.append(obj)
|
|
435
|
+
except OSError:
|
|
436
|
+
return None
|
|
437
|
+
if not rows:
|
|
438
|
+
return None
|
|
439
|
+
rows.sort(key=lambda r: r.get("timestamp", ""))
|
|
440
|
+
return rows[-1]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
# Freshness window resolution
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _resolve_max_age_hours(explicit: int | None) -> int:
|
|
449
|
+
if explicit is not None:
|
|
450
|
+
return max(0, int(explicit))
|
|
451
|
+
raw = os.environ.get(ENV_MAX_AGE_HOURS, "").strip()
|
|
452
|
+
if not raw:
|
|
453
|
+
return DEFAULT_MAX_AGE_HOURS
|
|
454
|
+
try:
|
|
455
|
+
parsed = int(raw)
|
|
456
|
+
except ValueError:
|
|
457
|
+
return DEFAULT_MAX_AGE_HOURS
|
|
458
|
+
return max(0, parsed)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def is_fetched_at_stale(
|
|
462
|
+
fetched_at: str | None,
|
|
463
|
+
*,
|
|
464
|
+
max_age_hours: int | None = None,
|
|
465
|
+
now: datetime | None = None,
|
|
466
|
+
) -> bool:
|
|
467
|
+
"""Return True when a cache entry's ``fetched_at`` is older than the window.
|
|
468
|
+
|
|
469
|
+
Pure, side-effect-free predicate shared with the #1476 triage:queue
|
|
470
|
+
defensive stale-state path so the freshness window is resolved the
|
|
471
|
+
same way everywhere (flag / ``DEFT_CACHE_MAX_AGE_HOURS`` env / 24h
|
|
472
|
+
default, via :func:`_resolve_max_age_hours`).
|
|
473
|
+
|
|
474
|
+
A missing / empty / unparseable ``fetched_at`` is treated as stale
|
|
475
|
+
(the cache cannot vouch for the entry's age). When the resolved
|
|
476
|
+
window is ``0`` (freshness disabled) nothing is stale. A negative
|
|
477
|
+
age (clock skew -- ``fetched_at`` in the future) is clamped to
|
|
478
|
+
fresh.
|
|
479
|
+
"""
|
|
480
|
+
if not isinstance(fetched_at, str) or not fetched_at.strip():
|
|
481
|
+
return True
|
|
482
|
+
max_age_h = _resolve_max_age_hours(max_age_hours)
|
|
483
|
+
if max_age_h <= 0:
|
|
484
|
+
return False
|
|
485
|
+
try:
|
|
486
|
+
fetched = _parse_iso(fetched_at)
|
|
487
|
+
except ValueError:
|
|
488
|
+
return True
|
|
489
|
+
age_h = ((now or _utc_now()) - fetched).total_seconds() / 3600.0
|
|
490
|
+
return age_h > max_age_h
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
# Core evaluator
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
_REMEDIATION_STALE = (
|
|
499
|
+
" Remediation:\n"
|
|
500
|
+
" deft triage:bootstrap # full re-populate, or\n"
|
|
501
|
+
" deft cache:fetch-all -- --source github-issue --repo <OWNER/NAME>\n"
|
|
502
|
+
" Override (audited): --allow-stale (or DEFT_CACHE_MAX_AGE_HOURS=<N>)."
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def evaluate(
|
|
507
|
+
project_root: Path,
|
|
508
|
+
*,
|
|
509
|
+
source: str = DEFAULT_SOURCE,
|
|
510
|
+
repo: str | None = None,
|
|
511
|
+
max_age_hours: int | None = None,
|
|
512
|
+
for_issue: int | None = None,
|
|
513
|
+
allow_stale: bool = False,
|
|
514
|
+
allow_missing_bootstrap: bool = False,
|
|
515
|
+
now: datetime | None = None,
|
|
516
|
+
) -> GateResult:
|
|
517
|
+
"""Pure-function gate. See module docstring for exit-code semantics.
|
|
518
|
+
|
|
519
|
+
Separated from :func:`main` so tests drive every branch without
|
|
520
|
+
``capsys`` plumbing or argv leak.
|
|
521
|
+
|
|
522
|
+
``allow_missing_bootstrap`` mirrors ``preflight_branch.py``'s
|
|
523
|
+
``--allow-missing-project-definition`` bootstrap escape: when
|
|
524
|
+
``.deft-cache/`` or ``vbrief/.eval/candidates.jsonl`` is missing the
|
|
525
|
+
gate returns exit 0 with a friendly info message instead of exit 2.
|
|
526
|
+
The framework repo's own ``task check`` uses this so a fresh
|
|
527
|
+
checkout that has not yet run ``deft triage:bootstrap`` is not
|
|
528
|
+
gated by its own cache-freshness verb. Consumers leave the flag
|
|
529
|
+
OFF so the gate fails loudly when their cache is missing.
|
|
530
|
+
"""
|
|
531
|
+
cache_root = project_root / CACHE_DIR_NAME
|
|
532
|
+
candidates = _candidates_path(project_root)
|
|
533
|
+
max_age_h = _resolve_max_age_hours(max_age_hours)
|
|
534
|
+
now_dt = now or _utc_now()
|
|
535
|
+
|
|
536
|
+
# --- Step 1: cache directory existence (config-error class) -----------
|
|
537
|
+
source_dir = cache_root / source
|
|
538
|
+
if not cache_root.is_dir() or not source_dir.is_dir():
|
|
539
|
+
if allow_missing_bootstrap and for_issue is None:
|
|
540
|
+
return GateResult(
|
|
541
|
+
0,
|
|
542
|
+
(
|
|
543
|
+
f"✓ deft cache-fresh: .deft-cache/{source}/ absent and "
|
|
544
|
+
"--allow-missing-bootstrap was passed -- treating as "
|
|
545
|
+
"bootstrap state (consumer runs `deft triage:bootstrap` "
|
|
546
|
+
"to opt in)."
|
|
547
|
+
),
|
|
548
|
+
)
|
|
549
|
+
msg = (
|
|
550
|
+
f"❌ deft cache-fresh: .deft-cache/{source}/ not present under "
|
|
551
|
+
f"{project_root}. The triage cache has not been populated.\n"
|
|
552
|
+
" Recovery: run `deft triage:bootstrap` (idempotent installer)\n"
|
|
553
|
+
" or `deft cache:fetch-all -- --source "
|
|
554
|
+
f"{source} --repo OWNER/NAME`."
|
|
555
|
+
)
|
|
556
|
+
return GateResult(2, msg)
|
|
557
|
+
|
|
558
|
+
# --- Step 2: candidates.jsonl readable (config-error class) -----------
|
|
559
|
+
if not candidates.is_file():
|
|
560
|
+
if allow_missing_bootstrap and for_issue is None:
|
|
561
|
+
return GateResult(
|
|
562
|
+
0,
|
|
563
|
+
(
|
|
564
|
+
f"✓ deft cache-fresh: {candidates.relative_to(project_root)} "
|
|
565
|
+
"absent and --allow-missing-bootstrap was passed -- "
|
|
566
|
+
"treating as bootstrap state."
|
|
567
|
+
),
|
|
568
|
+
)
|
|
569
|
+
msg = (
|
|
570
|
+
f"❌ deft cache-fresh: {candidates} missing.\n"
|
|
571
|
+
" Recovery: run `deft triage:bootstrap` to backfill the audit\n"
|
|
572
|
+
" log, or accept at least one candidate via\n"
|
|
573
|
+
" `deft triage:accept`."
|
|
574
|
+
)
|
|
575
|
+
return GateResult(2, msg)
|
|
576
|
+
|
|
577
|
+
# --- Step 3: repo resolution -----------------------------------------
|
|
578
|
+
resolved_repo = _resolve_repo(project_root, cache_root, source, explicit=repo)
|
|
579
|
+
if not resolved_repo:
|
|
580
|
+
msg = (
|
|
581
|
+
"❌ deft cache-fresh: cannot determine owner/repo. Pass --repo "
|
|
582
|
+
"OWNER/NAME, set DEFT_TRIAGE_REPO, or run inside a git checkout "
|
|
583
|
+
"whose `origin` is a github.com remote."
|
|
584
|
+
)
|
|
585
|
+
return GateResult(2, msg)
|
|
586
|
+
|
|
587
|
+
meta_paths = list(_iter_meta_paths(cache_root, source, resolved_repo))
|
|
588
|
+
if not meta_paths:
|
|
589
|
+
msg = (
|
|
590
|
+
"❌ deft cache-fresh: no cached entries under "
|
|
591
|
+
f".deft-cache/{source}/{resolved_repo}/.\n"
|
|
592
|
+
" Recovery: `deft cache:fetch-all -- --source "
|
|
593
|
+
f"{source} --repo {resolved_repo}`."
|
|
594
|
+
)
|
|
595
|
+
return GateResult(2, msg)
|
|
596
|
+
|
|
597
|
+
# --- Step 4: subscription filter (#1131) -----------------------------
|
|
598
|
+
scope_rules = _resolve_scope_rules(project_root)
|
|
599
|
+
scoped_meta_paths = _filter_scoped_meta_paths(meta_paths, scope_rules)
|
|
600
|
+
# #1245: distinguish a ``backfill-only cache`` state (the cache
|
|
601
|
+
# contains entries but none currently match the active subscription,
|
|
602
|
+
# AND the consumer has emitted at least one triage decision
|
|
603
|
+
# -- including ``triage:bootstrap``'s backfilled ``accept`` history
|
|
604
|
+
# rows) from a genuine misconfiguration (no in-scope cached entries
|
|
605
|
+
# AND no triage activity). The backfill-only state is the expected
|
|
606
|
+
# post-bootstrap shape on a repo whose currently-cached open issues
|
|
607
|
+
# do not happen to match the operator's narrow subscription; the
|
|
608
|
+
# session-start gate should pass so the pre-``start_agent`` gate
|
|
609
|
+
# stack composes cleanly. Downstream ``--for-issue`` dispatch still
|
|
610
|
+
# enforces per-issue scope + decision via :func:`_gate_for_issue`,
|
|
611
|
+
# so this relaxation only affects the cache-wide session check.
|
|
612
|
+
backfill_only_cache = False
|
|
613
|
+
if not scoped_meta_paths:
|
|
614
|
+
audit_state = _audit_log_state(candidates)
|
|
615
|
+
if audit_state == "populated":
|
|
616
|
+
# Fall through to Step 5's freshness window using the FULL
|
|
617
|
+
# ``meta_paths`` (not the empty scoped list) so a stale
|
|
618
|
+
# cache still fails loudly even when every entry is out
|
|
619
|
+
# of subscription. The Step 6 OK message uses the
|
|
620
|
+
# ``backfill_only_cache`` flag to emit a state-aware line.
|
|
621
|
+
backfill_only_cache = True
|
|
622
|
+
scoped_meta_paths = meta_paths
|
|
623
|
+
else:
|
|
624
|
+
msg = (
|
|
625
|
+
"❌ deft cache-fresh: every cached entry is outside the active "
|
|
626
|
+
"plan.policy.triageScope[] subscription, and the audit log "
|
|
627
|
+
"is empty (no triage decisions yet).\n"
|
|
628
|
+
" Recovery: widen the subscription (see "
|
|
629
|
+
"`deft triage:scope --list`), repopulate via "
|
|
630
|
+
"`deft cache:fetch-all`, or accept at least one candidate "
|
|
631
|
+
"via `deft triage:accept` once the cache has matching entries."
|
|
632
|
+
)
|
|
633
|
+
if allow_stale:
|
|
634
|
+
# Mirror the Step 5 stale-cache pattern: --allow-stale
|
|
635
|
+
# MUST NOT silently paper over a defer/reject/missing
|
|
636
|
+
# --for-issue decision. Run the per-issue gate FIRST
|
|
637
|
+
# and propagate any refusal; only fall through to the
|
|
638
|
+
# allow-stale exit 0 when the per-issue check is clean
|
|
639
|
+
# (or no --for-issue was passed).
|
|
640
|
+
if for_issue is not None:
|
|
641
|
+
for_issue_result = _gate_for_issue(
|
|
642
|
+
resolved_repo,
|
|
643
|
+
for_issue,
|
|
644
|
+
candidates=candidates,
|
|
645
|
+
scope_rules=scope_rules,
|
|
646
|
+
source_dir=source_dir,
|
|
647
|
+
project_root=project_root,
|
|
648
|
+
)
|
|
649
|
+
if for_issue_result.code != 0:
|
|
650
|
+
return for_issue_result
|
|
651
|
+
return GateResult(
|
|
652
|
+
0,
|
|
653
|
+
(
|
|
654
|
+
"⚠ deft cache-fresh: --allow-stale honoured but every "
|
|
655
|
+
"cached entry is out of scope; downstream tooling may "
|
|
656
|
+
"still refuse work."
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
return GateResult(1, msg)
|
|
660
|
+
|
|
661
|
+
# --- Step 5: freshness window ----------------------------------------
|
|
662
|
+
max_fetched: datetime | None = None
|
|
663
|
+
max_meta_path: Path | None = None
|
|
664
|
+
for meta_path in scoped_meta_paths:
|
|
665
|
+
meta = _read_meta(meta_path)
|
|
666
|
+
if not meta:
|
|
667
|
+
continue
|
|
668
|
+
stamp = meta.get("fetched_at")
|
|
669
|
+
if not isinstance(stamp, str) or not stamp:
|
|
670
|
+
continue
|
|
671
|
+
try:
|
|
672
|
+
fetched = _parse_iso(stamp)
|
|
673
|
+
except ValueError:
|
|
674
|
+
continue
|
|
675
|
+
if max_fetched is None or fetched > max_fetched:
|
|
676
|
+
max_fetched = fetched
|
|
677
|
+
max_meta_path = meta_path
|
|
678
|
+
|
|
679
|
+
if max_fetched is None:
|
|
680
|
+
msg = (
|
|
681
|
+
"❌ deft cache-fresh: no parseable `fetched_at` in any cached "
|
|
682
|
+
"meta.json. The cache may be corrupted.\n"
|
|
683
|
+
" Recovery: `deft cache:fetch-all -- --source "
|
|
684
|
+
f"{source} --repo {resolved_repo}`."
|
|
685
|
+
)
|
|
686
|
+
return GateResult(2, msg)
|
|
687
|
+
|
|
688
|
+
age = now_dt - max_fetched
|
|
689
|
+
if age < timedelta(0):
|
|
690
|
+
age = timedelta(0)
|
|
691
|
+
age_h = age.total_seconds() / 3600.0
|
|
692
|
+
|
|
693
|
+
stale = max_age_h > 0 and age_h > max_age_h
|
|
694
|
+
|
|
695
|
+
if stale and allow_stale:
|
|
696
|
+
warning = (
|
|
697
|
+
"⚠ deft cache-fresh: --allow-stale honoured; cache is "
|
|
698
|
+
f"{age_h:.1f}h old (max-age={max_age_h}h). Downstream tooling "
|
|
699
|
+
"may still refuse work."
|
|
700
|
+
)
|
|
701
|
+
# Still run the --for-issue gate so --allow-stale does not silently
|
|
702
|
+
# paper over a defer/reject decision.
|
|
703
|
+
if for_issue is not None:
|
|
704
|
+
for_issue_result = _gate_for_issue(
|
|
705
|
+
resolved_repo,
|
|
706
|
+
for_issue,
|
|
707
|
+
candidates=candidates,
|
|
708
|
+
scope_rules=scope_rules,
|
|
709
|
+
source_dir=source_dir,
|
|
710
|
+
project_root=project_root,
|
|
711
|
+
)
|
|
712
|
+
if for_issue_result.code != 0:
|
|
713
|
+
return for_issue_result
|
|
714
|
+
return GateResult(0, warning)
|
|
715
|
+
|
|
716
|
+
if stale:
|
|
717
|
+
msg = (
|
|
718
|
+
f"❌ deft cache-fresh: cache is {age_h:.1f}h old "
|
|
719
|
+
f"(max-age={max_age_h}h); newest entry "
|
|
720
|
+
f"{max_meta_path.relative_to(project_root) if max_meta_path else '?'}.\n"
|
|
721
|
+
f"{_REMEDIATION_STALE}"
|
|
722
|
+
)
|
|
723
|
+
return GateResult(1, msg)
|
|
724
|
+
|
|
725
|
+
# --- Step 6: --for-issue ---------------------------------------------
|
|
726
|
+
if for_issue is not None:
|
|
727
|
+
for_issue_result = _gate_for_issue(
|
|
728
|
+
resolved_repo,
|
|
729
|
+
for_issue,
|
|
730
|
+
candidates=candidates,
|
|
731
|
+
scope_rules=scope_rules,
|
|
732
|
+
source_dir=source_dir,
|
|
733
|
+
project_root=project_root,
|
|
734
|
+
)
|
|
735
|
+
if for_issue_result.code != 0:
|
|
736
|
+
return for_issue_result
|
|
737
|
+
|
|
738
|
+
# #1240: distinguish "fresh bootstrap, no triage actions yet" from
|
|
739
|
+
# "actively triaging". A zero-length audit log indicates the consumer
|
|
740
|
+
# just ran ``deft triage:bootstrap`` (step 5 seeded the empty file)
|
|
741
|
+
# but has not yet emitted any triage decision; the gate is still
|
|
742
|
+
# clean but the language acknowledges the operator's mental state.
|
|
743
|
+
# #1245: the ``backfill_only_cache`` flag set during Step 4 supplies
|
|
744
|
+
# a third state -- the cache holds entries but none match the active
|
|
745
|
+
# subscription, AND the audit log is populated (consumer is actively
|
|
746
|
+
# triaging). The gate passes so downstream tooling can run; the
|
|
747
|
+
# message names the state so the operator is not surprised that
|
|
748
|
+
# ``triage:queue`` etc. show zero in-scope rows.
|
|
749
|
+
audit_state = _audit_log_state(candidates)
|
|
750
|
+
if backfill_only_cache:
|
|
751
|
+
state_phrase = (
|
|
752
|
+
"backfill-only cache (no entries match "
|
|
753
|
+
"plan.policy.triageScope[]; audit log populated)"
|
|
754
|
+
)
|
|
755
|
+
in_scope_count = 0
|
|
756
|
+
elif audit_state == "empty":
|
|
757
|
+
state_phrase = "fresh bootstrap, no triage actions yet"
|
|
758
|
+
in_scope_count = len(scoped_meta_paths)
|
|
759
|
+
else:
|
|
760
|
+
state_phrase = "actively triaging"
|
|
761
|
+
in_scope_count = len(scoped_meta_paths)
|
|
762
|
+
msg = (
|
|
763
|
+
f"✓ deft cache-fresh: {resolved_repo} -- {in_scope_count} entry/ies "
|
|
764
|
+
f"in scope; newest fetched {age_h:.1f}h ago (max-age={max_age_h}h); "
|
|
765
|
+
f"{state_phrase}."
|
|
766
|
+
)
|
|
767
|
+
if for_issue is not None:
|
|
768
|
+
msg += f" Issue #{for_issue} latest decision = accept; in subscription scope."
|
|
769
|
+
return GateResult(0, msg)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _audit_log_state(candidates: Path) -> str:
|
|
773
|
+
"""Return one of ``"empty"`` / ``"populated"`` (#1240).
|
|
774
|
+
|
|
775
|
+
A zero-length file (post-#1240 bootstrap seed) is ``empty``; any
|
|
776
|
+
file that parses at least one non-blank line is ``populated``.
|
|
777
|
+
Errors reading the file fall back to ``empty`` so a corrupted
|
|
778
|
+
audit log doesn't claim the consumer is actively triaging.
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
if candidates.stat().st_size == 0:
|
|
782
|
+
return "empty"
|
|
783
|
+
except OSError:
|
|
784
|
+
return "empty"
|
|
785
|
+
try:
|
|
786
|
+
with candidates.open(encoding="utf-8") as fh:
|
|
787
|
+
for raw_line in fh:
|
|
788
|
+
if raw_line.strip():
|
|
789
|
+
return "populated"
|
|
790
|
+
except OSError:
|
|
791
|
+
return "empty"
|
|
792
|
+
return "empty"
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _gate_for_issue(
|
|
796
|
+
repo: str,
|
|
797
|
+
issue_number: int,
|
|
798
|
+
*,
|
|
799
|
+
candidates: Path,
|
|
800
|
+
scope_rules: list[dict[str, Any]] | None,
|
|
801
|
+
source_dir: Path,
|
|
802
|
+
project_root: Path,
|
|
803
|
+
) -> GateResult:
|
|
804
|
+
"""Run the ``--for-issue`` sub-gates: scope + latest-decision."""
|
|
805
|
+
# Subscription: read raw.json from the cache and verify the issue is
|
|
806
|
+
# matched by the rule set. If the issue isn't cached, treat as out of
|
|
807
|
+
# scope so the operator must explicitly fetch + accept it first.
|
|
808
|
+
owner, name = repo.split("/", 1) if "/" in repo else ("", "")
|
|
809
|
+
issue_meta = source_dir / owner / name / str(issue_number) / "meta.json"
|
|
810
|
+
raw = _read_raw_issue(issue_meta) if issue_meta.is_file() else None
|
|
811
|
+
if scope_rules and raw is not None:
|
|
812
|
+
if not _issue_in_scope(scope_rules, raw, project_root=project_root):
|
|
813
|
+
msg = (
|
|
814
|
+
f"❌ deft cache-fresh: issue #{issue_number} is OUTSIDE the "
|
|
815
|
+
"active plan.policy.triageScope[] subscription.\n"
|
|
816
|
+
" Recovery: widen the subscription (see "
|
|
817
|
+
"`deft triage:scope --list`) or open it via "
|
|
818
|
+
"`deft triage:accept -- --repo OWNER/NAME --issue "
|
|
819
|
+
f"{issue_number}` after confirming the scope rule covers it."
|
|
820
|
+
)
|
|
821
|
+
return GateResult(1, msg)
|
|
822
|
+
elif scope_rules and raw is None:
|
|
823
|
+
# We couldn't read the raw payload but rules are set; refuse so
|
|
824
|
+
# the operator must `deft cache:fetch-all` first.
|
|
825
|
+
msg = (
|
|
826
|
+
f"❌ deft cache-fresh: issue #{issue_number} is not present in "
|
|
827
|
+
f".deft-cache/{DEFAULT_SOURCE}/{repo}/ (cannot verify subscription).\n"
|
|
828
|
+
f" Recovery: `deft cache:fetch-all -- --source {DEFAULT_SOURCE} "
|
|
829
|
+
f"--repo {repo}` and retry."
|
|
830
|
+
)
|
|
831
|
+
return GateResult(1, msg)
|
|
832
|
+
|
|
833
|
+
# Latest-decision check.
|
|
834
|
+
decision = _latest_decision_for_issue(
|
|
835
|
+
candidates, repo=repo, issue_number=issue_number
|
|
836
|
+
)
|
|
837
|
+
if decision is None:
|
|
838
|
+
msg = (
|
|
839
|
+
f"❌ deft cache-fresh: issue #{issue_number} has no triage decision "
|
|
840
|
+
f"in {candidates.relative_to(project_root)}.\n"
|
|
841
|
+
" Recovery: `deft triage:accept -- --repo "
|
|
842
|
+
f"{repo} --issue {issue_number}` "
|
|
843
|
+
"before dispatching an implementation agent."
|
|
844
|
+
)
|
|
845
|
+
return GateResult(1, msg)
|
|
846
|
+
|
|
847
|
+
verdict = decision.get("decision", "")
|
|
848
|
+
if verdict != REQUIRED_DECISION:
|
|
849
|
+
msg = (
|
|
850
|
+
f"❌ deft cache-fresh: issue #{issue_number} latest decision "
|
|
851
|
+
f"is {verdict!r}, not {REQUIRED_DECISION!r} -- dispatch refused.\n"
|
|
852
|
+
f" Recovery: re-evaluate via `deft triage:status -- --repo {repo} "
|
|
853
|
+
f"--issue {issue_number}` and run `deft triage:accept` once the "
|
|
854
|
+
"item is ready, or pick a different issue."
|
|
855
|
+
)
|
|
856
|
+
return GateResult(1, msg)
|
|
857
|
+
|
|
858
|
+
return GateResult(0, f"✓ issue #{issue_number} cleared (decision=accept).")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
# ---------------------------------------------------------------------------
|
|
862
|
+
# CLI
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
867
|
+
parser = argparse.ArgumentParser(
|
|
868
|
+
prog="preflight_cache.py",
|
|
869
|
+
description=(
|
|
870
|
+
"Pre-`start_agent` cache-freshness gate (#1127). Refuses "
|
|
871
|
+
"implementation dispatch when the triage cache is stale, "
|
|
872
|
+
"missing, or the target issue's latest decision is not "
|
|
873
|
+
"`accept`. Subscription-aware via plan.policy.triageScope[] "
|
|
874
|
+
"(D12 / #1131)."
|
|
875
|
+
),
|
|
876
|
+
)
|
|
877
|
+
parser.add_argument(
|
|
878
|
+
"--project-root",
|
|
879
|
+
default=".",
|
|
880
|
+
help="Project root path (default: current working directory).",
|
|
881
|
+
)
|
|
882
|
+
parser.add_argument(
|
|
883
|
+
"--source",
|
|
884
|
+
default=DEFAULT_SOURCE,
|
|
885
|
+
help=(
|
|
886
|
+
"Cache source to inspect. v1 ships github-issue only "
|
|
887
|
+
"(default: github-issue)."
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
parser.add_argument(
|
|
891
|
+
"--repo",
|
|
892
|
+
default=None,
|
|
893
|
+
help=(
|
|
894
|
+
"Upstream repo slug 'owner/name'. Resolution precedence: "
|
|
895
|
+
"(1) --repo, (2) $DEFT_TRIAGE_REPO, (3) `git remote get-url "
|
|
896
|
+
"origin`, (4) single-repo auto-detect under .deft-cache/."
|
|
897
|
+
),
|
|
898
|
+
)
|
|
899
|
+
parser.add_argument(
|
|
900
|
+
"--max-age-hours",
|
|
901
|
+
type=int,
|
|
902
|
+
default=None,
|
|
903
|
+
help=(
|
|
904
|
+
"Override the freshness window in hours. Falls back to "
|
|
905
|
+
"$DEFT_CACHE_MAX_AGE_HOURS, then the built-in default (24h)."
|
|
906
|
+
),
|
|
907
|
+
)
|
|
908
|
+
parser.add_argument(
|
|
909
|
+
"--for-issue",
|
|
910
|
+
type=int,
|
|
911
|
+
default=None,
|
|
912
|
+
help=(
|
|
913
|
+
"Verify a specific issue's latest decision is `accept` AND "
|
|
914
|
+
"that it is covered by plan.policy.triageScope[] (D12). "
|
|
915
|
+
"Refuses dispatch on any other decision or out-of-scope match."
|
|
916
|
+
),
|
|
917
|
+
)
|
|
918
|
+
parser.add_argument(
|
|
919
|
+
"--allow-stale",
|
|
920
|
+
action="store_true",
|
|
921
|
+
help=(
|
|
922
|
+
"Audit-trail escape hatch: exits 0 with a stderr warning even "
|
|
923
|
+
"when the cache is stale. Per-shell only; never persisted."
|
|
924
|
+
),
|
|
925
|
+
)
|
|
926
|
+
parser.add_argument(
|
|
927
|
+
"--allow-missing-bootstrap",
|
|
928
|
+
action="store_true",
|
|
929
|
+
help=(
|
|
930
|
+
"Bootstrap fallback (mirrors preflight_branch.py's "
|
|
931
|
+
"--allow-missing-project-definition): treat a missing "
|
|
932
|
+
".deft-cache/ or candidates.jsonl as exit 0 instead of exit 2. "
|
|
933
|
+
"Used by the framework's own `task check` so a fresh checkout "
|
|
934
|
+
"is not gated by its own verify:cache-fresh verb. Ignored when "
|
|
935
|
+
"--for-issue is passed."
|
|
936
|
+
),
|
|
937
|
+
)
|
|
938
|
+
parser.add_argument(
|
|
939
|
+
"--quiet",
|
|
940
|
+
action="store_true",
|
|
941
|
+
help="Suppress the OK message (errors still print to stderr).",
|
|
942
|
+
)
|
|
943
|
+
return parser
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def main(argv: list[str] | None = None) -> int:
|
|
947
|
+
parser = _build_parser()
|
|
948
|
+
args = parser.parse_args(argv)
|
|
949
|
+
project_root = Path(args.project_root).resolve()
|
|
950
|
+
result = evaluate(
|
|
951
|
+
project_root,
|
|
952
|
+
source=args.source,
|
|
953
|
+
repo=args.repo,
|
|
954
|
+
max_age_hours=args.max_age_hours,
|
|
955
|
+
for_issue=args.for_issue,
|
|
956
|
+
allow_stale=args.allow_stale,
|
|
957
|
+
allow_missing_bootstrap=args.allow_missing_bootstrap,
|
|
958
|
+
)
|
|
959
|
+
if result.code == 0:
|
|
960
|
+
if not args.quiet:
|
|
961
|
+
# Warning lines start with ⚠ and route to stderr so a CI run
|
|
962
|
+
# that pipes stdout into a log still captures them next to
|
|
963
|
+
# any later failures.
|
|
964
|
+
if result.message.startswith("⚠"):
|
|
965
|
+
print(result.message, file=sys.stderr)
|
|
966
|
+
else:
|
|
967
|
+
print(result.message)
|
|
968
|
+
else:
|
|
969
|
+
print(result.message, file=sys.stderr)
|
|
970
|
+
return result.code
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
if __name__ == "__main__":
|
|
974
|
+
sys.exit(main())
|