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