@deftai/directive-content 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env python3
2
+ """triage_refresh.py -- Story 4 pre-swarm freshness gate (#883 Story 3 rebind).
3
+
4
+ Implements ``task triage:refresh-active``:
5
+
6
+ 1. Walks ``vbrief/active/*.vbrief.json`` and extracts
7
+ ``x-vbrief/github-issue`` references.
8
+ 2. For every (repo, issue) pair, reads the cached ``meta.json.fetched_at``
9
+ via :func:`scripts.cache.cache_get` (#883 Story 2) and compares it to a
10
+ live ``gh issue view <N> --json updatedAt``. Drift exists when the
11
+ upstream ``updatedAt`` is newer than the cached ``fetched_at`` (the
12
+ issue moved after we mirrored it) OR when the cache has no entry for
13
+ the issue at all.
14
+ 3. Surfaces drifted items via a three-way prompt:
15
+
16
+ - ``proceed-with-stale`` -- record an audit annotation via Story 2.
17
+ - ``refresh-and-update-local`` -- call ``cache_put`` with a fresh
18
+ ``gh issue view`` payload to re-cache the issue.
19
+ - ``defer-from-this-batch`` -- skip the issue; caller decides later.
20
+
21
+ Empty ``vbrief/active/`` is a no-op (clean exit). The freshness primitive
22
+ introduced here is consumed by ``#868`` (lock-comment protocol).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import contextlib
29
+ import importlib
30
+ import json
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ import uuid
35
+ from collections.abc import Callable
36
+ from dataclasses import dataclass, field
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ # Pre-compiled regex used for both repo + issue extraction.
42
+ _ISSUE_URL_RE = re.compile(
43
+ r"github\.com/(?P<repo>[^/]+/[^/]+)/issues/(?P<num>\d+)",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+ #: Cache source consumed by triage v1 (only github-issue is supported).
48
+ _CACHE_SOURCE: str = "github-issue"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # vBRIEF discovery + reference extraction
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _iter_active_vbriefs(active_dir: Path) -> list[Path]:
57
+ """Return active vBRIEFs sorted by filename. Missing dir returns ``[]``."""
58
+
59
+ if not active_dir.is_dir():
60
+ return []
61
+ return sorted(active_dir.glob("*.vbrief.json"))
62
+
63
+
64
+ def _extract_issue_refs(vbrief_path: Path) -> list[tuple[str, int]]:
65
+ """Return ``(repo, issue_number)`` tuples extracted from references."""
66
+
67
+ try:
68
+ data = json.loads(vbrief_path.read_text(encoding="utf-8"))
69
+ except (OSError, json.JSONDecodeError):
70
+ return []
71
+
72
+ if not isinstance(data, dict):
73
+ return []
74
+ plan = data.get("plan", {})
75
+ if not isinstance(plan, dict):
76
+ return []
77
+
78
+ out: list[tuple[str, int]] = []
79
+ for ref in plan.get("references", []) or []:
80
+ if not isinstance(ref, dict):
81
+ continue
82
+ if ref.get("type") != "x-vbrief/github-issue":
83
+ continue
84
+ uri = str(ref.get("uri", ""))
85
+ match = _ISSUE_URL_RE.search(uri)
86
+ if not match:
87
+ continue
88
+ out.append((match.group("repo"), int(match.group("num"))))
89
+ return out
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Cache module loader + drift primitives
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _load_cache_module() -> Any | None:
98
+ """Return the unified cache module, or ``None`` if not importable."""
99
+
100
+ for candidate in ("cache", "scripts.cache"):
101
+ try:
102
+ return importlib.import_module(candidate)
103
+ except ModuleNotFoundError:
104
+ continue
105
+ return None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class DriftRecord:
110
+ """A single (repo, issue) drift observation."""
111
+
112
+ repo: str
113
+ issue_number: int
114
+ cached_fetched_at: str | None
115
+ live_updated_at: str
116
+ vbrief_path: Path
117
+
118
+
119
+ def _fetch_live_updated_at(repo: str, issue_number: int) -> str:
120
+ """Live fetch via ``gh issue view`` -- returns empty string on missing field."""
121
+
122
+ cmd = [
123
+ "gh",
124
+ "issue",
125
+ "view",
126
+ str(issue_number),
127
+ "--repo",
128
+ repo,
129
+ "--json",
130
+ "updatedAt",
131
+ ]
132
+ completed = subprocess.run( # noqa: S603
133
+ cmd, capture_output=True, text=True, check=True
134
+ )
135
+ payload = json.loads(completed.stdout or "{}")
136
+ return str(payload.get("updatedAt") or "")
137
+
138
+
139
+ def _load_cached_fetched_at(
140
+ repo: str,
141
+ issue_number: int,
142
+ project_root: Path,
143
+ *,
144
+ cache_module: Any | None = None,
145
+ ) -> str | None:
146
+ """Read cached ``meta.json.fetched_at`` via :func:`scripts.cache.cache_get`.
147
+
148
+ Returns ``None`` when the cache entry is missing, when the cache module
149
+ is not importable, or when the entry's meta.json fails schema
150
+ validation. Callers treat ``None`` as "drift" (the cache cannot vouch
151
+ for the issue's current state).
152
+ """
153
+
154
+ cache_mod = cache_module if cache_module is not None else _load_cache_module()
155
+ if cache_mod is None:
156
+ return None
157
+ cache_get = getattr(cache_mod, "cache_get", None)
158
+ if not callable(cache_get):
159
+ return None
160
+ not_found_exc = getattr(cache_mod, "CacheNotFoundError", LookupError)
161
+ validation_exc = getattr(cache_mod, "CacheValidationError", ValueError)
162
+ cache_error_exc = getattr(cache_mod, "CacheError", RuntimeError)
163
+ key = f"{repo}/{int(issue_number)}"
164
+ try:
165
+ result = cache_get(
166
+ _CACHE_SOURCE,
167
+ key,
168
+ cache_root=project_root / ".deft-cache",
169
+ allow_stale=True,
170
+ )
171
+ except not_found_exc: # type: ignore[misc]
172
+ return None
173
+ except (validation_exc, cache_error_exc): # type: ignore[misc]
174
+ return None
175
+ meta = getattr(result, "meta", None)
176
+ if not isinstance(meta, dict):
177
+ return None
178
+ value = meta.get("fetched_at")
179
+ return str(value) if value is not None else None
180
+
181
+
182
+ FetchLive = Callable[[str, int], str]
183
+ CacheLoader = Callable[[str, int, Path], str | None]
184
+
185
+
186
+ def _is_drift(cached_fetched_at: str | None, live_updated_at: str) -> bool:
187
+ """Return True iff the live timestamp postdates the cached fetch.
188
+
189
+ Missing-cache (``cached_fetched_at`` is None) is always drift -- the
190
+ cache has nothing to vouch for. Empty live timestamps short-circuit to
191
+ no-drift so a malformed gh response cannot fabricate a drift signal.
192
+ """
193
+
194
+ if not live_updated_at:
195
+ return False
196
+ if cached_fetched_at is None:
197
+ return True
198
+ # ISO-8601 strings sort lexicographically when both carry the canonical
199
+ # ``Z`` suffix. cache.py's ``_utc_iso`` and gh's ``updatedAt`` both emit
200
+ # the Z form, so a string comparison is correct.
201
+ return live_updated_at > cached_fetched_at
202
+
203
+
204
+ def detect_drift(
205
+ active_dir: Path,
206
+ project_root: Path,
207
+ *,
208
+ fetch_live: FetchLive | None = None,
209
+ cache_loader: CacheLoader | None = None,
210
+ skipped_out: list[tuple[str, int, str]] | None = None,
211
+ checked_out: list[tuple[str, int]] | None = None,
212
+ out: Any | None = None,
213
+ ) -> list[DriftRecord]:
214
+ """Walk active vBRIEFs and return drifted (repo, issue) records.
215
+
216
+ Drift is computed against ``meta.json.fetched_at`` -- the issue's
217
+ upstream ``updatedAt`` is compared against the cache's record of when
218
+ we last mirrored it. A live-fetch failure (network / auth / malformed
219
+ gh response) is logged on ``out`` and recorded in ``skipped_out``;
220
+ callers treat skips as ``unverified`` rather than ``fresh`` so an
221
+ outage cannot masquerade as freshness.
222
+ """
223
+
224
+ fetch_live = fetch_live or _fetch_live_updated_at
225
+ cache_loader = cache_loader or _load_cached_fetched_at
226
+ sink = out or sys.stderr
227
+
228
+ drifts: list[DriftRecord] = []
229
+ seen: set[tuple[str, int]] = set()
230
+
231
+ for vbrief in _iter_active_vbriefs(active_dir):
232
+ for repo, num in _extract_issue_refs(vbrief):
233
+ key = (repo, num)
234
+ if key in seen:
235
+ continue
236
+ seen.add(key)
237
+ if checked_out is not None:
238
+ checked_out.append(key)
239
+ cached = cache_loader(repo, num, project_root)
240
+ try:
241
+ live = fetch_live(repo, num)
242
+ except (subprocess.CalledProcessError, json.JSONDecodeError, OSError) as exc:
243
+ reason = f"{type(exc).__name__}: {exc}"
244
+ print(
245
+ f"[triage:refresh-active] WARN: live fetch skipped for "
246
+ f"{repo}#{num} ({reason})",
247
+ file=sink,
248
+ )
249
+ if skipped_out is not None:
250
+ skipped_out.append((repo, num, reason))
251
+ continue
252
+ if _is_drift(cached, live):
253
+ drifts.append(
254
+ DriftRecord(
255
+ repo=repo,
256
+ issue_number=num,
257
+ cached_fetched_at=cached,
258
+ live_updated_at=live,
259
+ vbrief_path=vbrief,
260
+ )
261
+ )
262
+ return drifts
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Three-way prompt + side-effect surfaces
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ PROMPT_OPTIONS: dict[str, str] = {
271
+ "1": "proceed-with-stale",
272
+ "2": "refresh-and-update-local",
273
+ "3": "defer-from-this-batch",
274
+ }
275
+
276
+
277
+ def _prompt_user(
278
+ drift: DriftRecord,
279
+ *,
280
+ input_fn: Callable[[str], str] = input,
281
+ out: Any | None = None,
282
+ ) -> str:
283
+ """Render the three-way prompt and return the canonical choice keyword."""
284
+
285
+ sink = out or sys.stdout
286
+ print(f"\nDrift detected for {drift.repo}#{drift.issue_number}:", file=sink)
287
+ print(f" cached fetched_at: {drift.cached_fetched_at!r}", file=sink)
288
+ print(f" live updatedAt: {drift.live_updated_at!r}", file=sink)
289
+ print(f" vBRIEF: {drift.vbrief_path}", file=sink)
290
+ print(" 1) proceed-with-stale", file=sink)
291
+ print(" 2) refresh-and-update-local", file=sink)
292
+ print(" 3) defer-from-this-batch", file=sink)
293
+ raw = input_fn("Choose [1/2/3]: ").strip()
294
+ return PROMPT_OPTIONS.get(raw, "defer-from-this-batch")
295
+
296
+
297
+ def _refresh_and_update_local(
298
+ repo: str,
299
+ issue_number: int,
300
+ project_root: Path,
301
+ *,
302
+ cache_module: Any | None = None,
303
+ ) -> None:
304
+ """Re-cache ``repo#issue_number`` via :func:`scripts.cache.cache_put`.
305
+
306
+ Fetches a fresh ``gh issue view`` payload and writes it through the
307
+ unified cache so the next freshness pass observes the up-to-date
308
+ ``meta.json.fetched_at``. Tolerates an absent cache module (Story 2
309
+ not yet on the branch); the caller logs the refreshed status from
310
+ the surrounding context.
311
+ """
312
+
313
+ cache_mod = cache_module if cache_module is not None else _load_cache_module()
314
+ if cache_mod is None:
315
+ return
316
+ cache_put = getattr(cache_mod, "cache_put", None)
317
+ if not callable(cache_put):
318
+ return
319
+
320
+ cmd = [
321
+ "gh",
322
+ "issue",
323
+ "view",
324
+ str(issue_number),
325
+ "--repo",
326
+ repo,
327
+ "--json",
328
+ "number,title,body,state,labels,author,createdAt,updatedAt,url",
329
+ ]
330
+ try:
331
+ completed = subprocess.run( # noqa: S603
332
+ cmd, capture_output=True, text=True, check=True
333
+ )
334
+ except (subprocess.SubprocessError, OSError):
335
+ return
336
+ try:
337
+ raw = json.loads(completed.stdout or "{}")
338
+ except json.JSONDecodeError:
339
+ return
340
+ if not isinstance(raw, dict):
341
+ return
342
+ if "number" not in raw or not isinstance(raw["number"], int):
343
+ raw["number"] = int(issue_number)
344
+
345
+ key = f"{repo}/{int(issue_number)}"
346
+ try:
347
+ cache_put(
348
+ _CACHE_SOURCE,
349
+ key,
350
+ raw,
351
+ cache_root=project_root / ".deft-cache",
352
+ )
353
+ except Exception: # noqa: BLE001 -- best-effort refresh
354
+ return
355
+
356
+
357
+ def _record_audit_annotation(
358
+ repo: str,
359
+ issue_number: int,
360
+ annotation: str,
361
+ *,
362
+ actor: str = "agent:freshness-gate",
363
+ log_module: Any | None = None,
364
+ out: Any | None = None,
365
+ ) -> None:
366
+ """Append a ``freshness-annotation`` entry via Story 2's ``candidates_log``.
367
+
368
+ No-op if Story 2 isn't on the import path. Story 2 ships a FROZEN
369
+ decision vocabulary so the schema rejects the ``freshness-annotation``
370
+ decision; the rejection is degraded to a stderr WARN rather than a
371
+ fatal exception (Greptile P1, PR #875).
372
+ """
373
+
374
+ sink = out or sys.stderr
375
+ if log_module is None:
376
+ for candidate in ("candidates_log", "scripts.candidates_log"):
377
+ try:
378
+ log_module = importlib.import_module(candidate)
379
+ break
380
+ except ModuleNotFoundError:
381
+ continue
382
+ if log_module is None:
383
+ return
384
+ append = getattr(log_module, "append", None)
385
+ if not callable(append):
386
+ return
387
+
388
+ new_id = getattr(log_module, "new_decision_id", None)
389
+ decision_id = str(new_id()) if callable(new_id) else str(uuid.uuid4())
390
+
391
+ entry = {
392
+ "decision_id": decision_id,
393
+ "decision": "freshness-annotation",
394
+ "repo": repo,
395
+ "issue_number": issue_number,
396
+ "actor": actor,
397
+ "reason": annotation,
398
+ "timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
399
+ }
400
+ try:
401
+ append(entry)
402
+ except ValueError as exc:
403
+ print(
404
+ f"[triage:refresh-active] WARN: audit annotation for "
405
+ f"{repo}#{issue_number} not persisted -- candidates_log "
406
+ f"rejected the entry ({type(exc).__name__}: {exc}). The "
407
+ f"proceed-with-stale choice has been logged to stdout but "
408
+ f"the JSONL trail does not yet recognize 'freshness-"
409
+ f"annotation'; extend the Story 2 schema to capture it.",
410
+ file=sink,
411
+ )
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # High-level orchestration
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ @dataclass
420
+ class FreshnessSummary:
421
+ """Aggregate result of a ``refresh_active`` call."""
422
+
423
+ total_active: int
424
+ drifts_detected: int
425
+ proceeded: list[tuple[str, int]] = field(default_factory=list)
426
+ refreshed: list[tuple[str, int]] = field(default_factory=list)
427
+ deferred: list[tuple[str, int]] = field(default_factory=list)
428
+ skipped: list[tuple[str, int]] = field(default_factory=list)
429
+
430
+
431
+ RefreshLocal = Callable[[str, int, Path], None]
432
+ AuditWriter = Callable[[str, int, str], None]
433
+
434
+
435
+ def _evaluate_resume_step(project_root: Path, *, out: Any) -> None:
436
+ """Best-effort resume-condition evaluation hook (#1123 / D3).
437
+
438
+ Runs after the freshness pass so any defer entries whose ``resume_on``
439
+ condition fires get a ``resume-eligible`` audit-log marker before the
440
+ operator next consults the queue. Tolerates absence of the
441
+ ``resume_conditions`` module on slim test checkouts.
442
+ """
443
+ try:
444
+ rc = importlib.import_module("resume_conditions")
445
+ except ModuleNotFoundError:
446
+ try:
447
+ rc = importlib.import_module("scripts.resume_conditions")
448
+ except ModuleNotFoundError:
449
+ return
450
+ try:
451
+ appended = rc.evaluate_resume_eligibility(project_root)
452
+ except Exception as exc: # noqa: BLE001 -- best-effort; surface failure
453
+ print(
454
+ f"[triage:refresh-active] WARN: resume-condition eval failed: "
455
+ f"{type(exc).__name__}: {exc}",
456
+ file=out,
457
+ )
458
+ return
459
+ if appended:
460
+ print(
461
+ f"[triage:refresh-active] resume-eligible: {len(appended)} "
462
+ "defer entr(ies) fired",
463
+ file=out,
464
+ )
465
+
466
+
467
+ def refresh_active(
468
+ project_root: Path,
469
+ *,
470
+ active_dir: Path | None = None,
471
+ input_fn: Callable[[str], str] = input,
472
+ fetch_live: FetchLive | None = None,
473
+ cache_loader: CacheLoader | None = None,
474
+ refresh_local: RefreshLocal | None = None,
475
+ audit_writer: AuditWriter | None = None,
476
+ out: Any | None = None,
477
+ ) -> FreshnessSummary:
478
+ """Run the freshness gate end-to-end. Returns a :class:`FreshnessSummary`.
479
+
480
+ Side effect (#1123 / D3): after the freshness pass, walks open
481
+ ``defer`` audit entries with non-null ``resume_on`` and appends a
482
+ ``resume-eligible`` audit row for each condition that fires. The
483
+ evaluation is idempotent so repeated invocations do NOT duplicate
484
+ markers.
485
+ """
486
+
487
+ sink = out or sys.stdout
488
+ active_dir = active_dir or (project_root / "vbrief" / "active")
489
+ refresh_local = refresh_local or _refresh_and_update_local
490
+ audit_writer = audit_writer or _record_audit_annotation
491
+
492
+ active_files = _iter_active_vbriefs(active_dir)
493
+ if not active_files:
494
+ print("[triage:refresh-active] vbrief/active/ is empty -- no-op", file=sink)
495
+ # Still run the resume-eligible pass: a maintainer can keep a defer
496
+ # queue going while having no active scope, and a fired resume
497
+ # condition should surface even then.
498
+ _evaluate_resume_step(project_root, out=sink)
499
+ return FreshnessSummary(0, 0)
500
+
501
+ skipped_records: list[tuple[str, int, str]] = []
502
+ checked_pairs: list[tuple[str, int]] = []
503
+ drifts = detect_drift(
504
+ active_dir,
505
+ project_root,
506
+ fetch_live=fetch_live,
507
+ cache_loader=cache_loader,
508
+ skipped_out=skipped_records,
509
+ checked_out=checked_pairs,
510
+ out=sink,
511
+ )
512
+ skipped_pairs = [(repo, num) for (repo, num, _reason) in skipped_records]
513
+ if not drifts:
514
+ if skipped_pairs:
515
+ print(
516
+ f"[triage:refresh-active] WARN: no drift detected, but "
517
+ f"{len(skipped_pairs)} of {len(checked_pairs)} "
518
+ f"(repo, issue) fetch(es) were skipped (treat freshness "
519
+ f"signal as unverified)",
520
+ file=sink,
521
+ )
522
+ else:
523
+ print(
524
+ f"[triage:refresh-active] all {len(active_files)} active vBRIEFs fresh",
525
+ file=sink,
526
+ )
527
+ summary = FreshnessSummary(len(active_files), 0)
528
+ summary.skipped = skipped_pairs
529
+ return summary
530
+
531
+ summary = FreshnessSummary(len(active_files), len(drifts))
532
+ summary.skipped = skipped_pairs
533
+ for drift in drifts:
534
+ choice = _prompt_user(drift, input_fn=input_fn, out=sink)
535
+ if choice == "proceed-with-stale":
536
+ audit_writer(
537
+ drift.repo,
538
+ drift.issue_number,
539
+ f"proceed-with-stale: cached_fetched_at={drift.cached_fetched_at} "
540
+ f"live_updated_at={drift.live_updated_at}",
541
+ )
542
+ summary.proceeded.append((drift.repo, drift.issue_number))
543
+ print(
544
+ f"[triage:refresh-active] {drift.repo}#{drift.issue_number} "
545
+ "proceed-with-stale (audit recorded)",
546
+ file=sink,
547
+ )
548
+ elif choice == "refresh-and-update-local":
549
+ refresh_local(drift.repo, drift.issue_number, project_root)
550
+ summary.refreshed.append((drift.repo, drift.issue_number))
551
+ print(
552
+ f"[triage:refresh-active] {drift.repo}#{drift.issue_number} "
553
+ "refreshed-and-updated-local",
554
+ file=sink,
555
+ )
556
+ else:
557
+ summary.deferred.append((drift.repo, drift.issue_number))
558
+ print(
559
+ f"[triage:refresh-active] {drift.repo}#{drift.issue_number} "
560
+ "deferred-from-this-batch",
561
+ file=sink,
562
+ )
563
+ # #1123 / D3: emit resume-eligible markers for any open defer whose
564
+ # condition has fired since the last evaluation pass.
565
+ _evaluate_resume_step(project_root, out=sink)
566
+ return summary
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # CLI plumbing
571
+ # ---------------------------------------------------------------------------
572
+
573
+
574
+ def _build_parser() -> argparse.ArgumentParser:
575
+ parser = argparse.ArgumentParser(
576
+ prog="triage_refresh",
577
+ description=(
578
+ "Pre-swarm freshness gate for vbrief/active/ "
579
+ "(#845 Story 4 / #883 Story 3 rebind)"
580
+ ),
581
+ )
582
+ parser.add_argument(
583
+ "--project-root",
584
+ default=".",
585
+ help="project root containing vbrief/active/ (default: cwd)",
586
+ )
587
+ return parser
588
+
589
+
590
+ def _reconfigure_utf8() -> None:
591
+ """Best-effort UTF-8 stdout/stderr on Windows hosts (mirrors #814)."""
592
+
593
+ if sys.platform != "win32":
594
+ return
595
+ for stream_name in ("stdout", "stderr"):
596
+ stream = getattr(sys, stream_name, None)
597
+ reconfigure = getattr(stream, "reconfigure", None)
598
+ if callable(reconfigure):
599
+ with contextlib.suppress(Exception):
600
+ reconfigure(encoding="utf-8")
601
+
602
+
603
+ def main(argv: list[str] | None = None) -> int:
604
+ _reconfigure_utf8()
605
+ # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
606
+ from triage_help import intercept_help
607
+
608
+ rc = intercept_help("triage_refresh", argv)
609
+ if rc is not None:
610
+ return rc
611
+ args = _build_parser().parse_args(argv)
612
+ project_root = Path(args.project_root).resolve()
613
+ refresh_active(project_root)
614
+ return 0
615
+
616
+
617
+ # Re-exported helper aliases so tests can monkeypatch a single seam without
618
+ # reaching into private names. They are intentionally identifier-only -- the
619
+ # implementations live above.
620
+ fetch_live_updated_at: FetchLive = _fetch_live_updated_at
621
+ load_cached_fetched_at: CacheLoader = _load_cached_fetched_at
622
+ iter_active_vbriefs: Callable[[Path], list[Path]] = _iter_active_vbriefs
623
+ extract_issue_refs: Callable[[Path], list[tuple[str, int]]] = _extract_issue_refs
624
+ record_audit_annotation: Callable[..., None] = _record_audit_annotation
625
+
626
+
627
+ __all__ = [
628
+ "DriftRecord",
629
+ "FreshnessSummary",
630
+ "PROMPT_OPTIONS",
631
+ "detect_drift",
632
+ "extract_issue_refs",
633
+ "fetch_live_updated_at",
634
+ "iter_active_vbriefs",
635
+ "load_cached_fetched_at",
636
+ "main",
637
+ "record_audit_annotation",
638
+ "refresh_active",
639
+ ]
640
+
641
+
642
+ if __name__ == "__main__":
643
+ sys.exit(main())