@deftai/directive-content 0.55.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,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())