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