@deftai/directive-content 0.59.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- 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())
|