@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,387 +0,0 @@
1
- """scope_audit_log.py -- append-only audit log for scope lifecycle decisions.
2
-
3
- Public surface:
4
- append(entry: dict, *, log_path: Path | None = None) -> str
5
- read_all(*, log_path: Path | None = None) -> list[dict]
6
- find_by_path(vbrief_path: str, *, log_path: Path | None = None) -> list[dict]
7
- latest_for_path(vbrief_path: str, action: str | None = None,
8
- *, log_path: Path | None = None) -> dict | None
9
- new_decision_id() -> str
10
- canonical_log_path(project_root: Path) -> Path
11
-
12
- Storage:
13
- ``<project_root>/vbrief/.eval/scope-lifecycle.jsonl`` -- one JSON object
14
- per line, UTF-8. Parent directory is created on first append. The file is
15
- operator-private and is gitignored alongside ``candidates.jsonl`` /
16
- ``summary-history.jsonl`` (#1144). The ``vbrief/.eval/*.jsonl
17
- merge=union`` rule in ``.gitattributes`` covers single-operator rebases.
18
-
19
- Concurrency:
20
- Mirrors ``candidates_log.py`` (#845 Story 2):
21
-
22
- - Cross-process safety: an advisory lock is held on a sidecar
23
- ``scope-lifecycle.jsonl.lock`` file via ``msvcrt.locking`` on Windows
24
- and ``fcntl.flock`` on POSIX while the writer appends a single line.
25
- - In-process thread safety: a module-level ``threading.Lock`` serialises
26
- appends from threads in the same Python process.
27
-
28
- Entry shape (operator-facing -- separate from ``candidates.jsonl``, which is
29
- the FROZEN triage schema):
30
-
31
- {
32
- "decision_id": "<uuid4>",
33
- "timestamp": "2026-05-17T21:05:00Z",
34
- "action": "demote",
35
- "vbrief_path": "vbrief/proposed/2026-05-17-1121-d1-scope-demote.vbrief.json",
36
- "from_status": "pending",
37
- "to_status": "proposed",
38
- "actor": "operator",
39
- "demote_meta": {
40
- "was_promoted": true,
41
- "original_promotion_decision_id": null,
42
- "days_in_pending": 12,
43
- "demote_reason": "operator-requested",
44
- "demoted_from": "pending"
45
- }
46
- }
47
-
48
- The ``action`` vocabulary starts as ``{"demote"}``; future scope-lifecycle
49
- audit emitters MAY widen it (e.g. ``"promote"``) so this writer keeps the
50
- field free-form. ``demote_meta`` is the only action-specific block this
51
- module recognises; readers MUST tolerate entries that lack it (forward-compat
52
- for future actions).
53
- """
54
-
55
- from __future__ import annotations
56
-
57
- import json
58
- import logging
59
- import os
60
- import re
61
- import sys
62
- import threading
63
- import time
64
- import uuid
65
- from collections.abc import Iterator
66
- from contextlib import contextmanager, suppress
67
- from datetime import UTC, datetime
68
- from pathlib import Path
69
- from typing import Any
70
-
71
- LOG = logging.getLogger(__name__)
72
-
73
- _AUDIT_LOG_RELPATH = Path("vbrief") / ".eval" / "scope-lifecycle.jsonl"
74
-
75
- _UUID_RE = re.compile(
76
- r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
77
- )
78
- # Match candidates_log: UTC-only timestamps with literal Z suffix so a
79
- # downstream lexicographic sort is chronologically correct.
80
- _ISO8601_RE = re.compile(
81
- r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$"
82
- )
83
-
84
- _REQUIRED_FIELDS: tuple[str, ...] = (
85
- "decision_id",
86
- "timestamp",
87
- "action",
88
- "vbrief_path",
89
- "from_status",
90
- "to_status",
91
- "actor",
92
- )
93
- _DEMOTE_META_REQUIRED: tuple[str, ...] = (
94
- "was_promoted",
95
- "original_promotion_decision_id",
96
- "days_in_pending",
97
- "demote_reason",
98
- "demoted_from",
99
- )
100
-
101
- _thread_lock = threading.Lock()
102
-
103
-
104
- class ScopeAuditLogError(ValueError):
105
- """Raised when an entry passed to :func:`append` fails validation."""
106
-
107
-
108
- # ---------------------------------------------------------------------------
109
- # Public helpers
110
- # ---------------------------------------------------------------------------
111
-
112
-
113
- def new_decision_id() -> str:
114
- """Return a fresh UUID4 string for use as ``decision_id``."""
115
- return str(uuid.uuid4())
116
-
117
-
118
- def utc_now_iso() -> str:
119
- """Return the current UTC time as an ISO-8601 string with literal Z."""
120
- return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
121
-
122
-
123
- def canonical_log_path(project_root: Path) -> Path:
124
- """Resolve the canonical audit log path under *project_root*."""
125
- return project_root / _AUDIT_LOG_RELPATH
126
-
127
-
128
- # ---------------------------------------------------------------------------
129
- # Validation
130
- # ---------------------------------------------------------------------------
131
-
132
-
133
- def _validate_entry(entry: Any) -> None:
134
- if not isinstance(entry, dict):
135
- raise ScopeAuditLogError(
136
- f"entry must be a dict, got {type(entry).__name__}"
137
- )
138
-
139
- missing = [f for f in _REQUIRED_FIELDS if f not in entry]
140
- if missing:
141
- raise ScopeAuditLogError(
142
- f"entry missing required field(s): {missing}"
143
- )
144
-
145
- decision_id = entry["decision_id"]
146
- if not isinstance(decision_id, str) or not _UUID_RE.match(decision_id):
147
- raise ScopeAuditLogError(
148
- f"decision_id must be a UUID string, got {decision_id!r}"
149
- )
150
-
151
- timestamp = entry["timestamp"]
152
- if not isinstance(timestamp, str) or not _ISO8601_RE.match(timestamp):
153
- raise ScopeAuditLogError(
154
- f"timestamp must be ISO-8601 UTC with Z suffix, got {timestamp!r}"
155
- )
156
-
157
- for field in ("action", "vbrief_path", "from_status", "to_status", "actor"):
158
- value = entry[field]
159
- if not isinstance(value, str) or not value:
160
- raise ScopeAuditLogError(
161
- f"{field} must be a non-empty string, got {value!r}"
162
- )
163
-
164
- # demote_meta is required only for action == "demote" (forward-compat
165
- # for future scope-lifecycle audit emitters).
166
- if entry["action"] == "demote":
167
- meta = entry.get("demote_meta")
168
- if not isinstance(meta, dict):
169
- raise ScopeAuditLogError(
170
- f"action='demote' requires a 'demote_meta' object, got {meta!r}"
171
- )
172
- _validate_demote_meta(meta)
173
-
174
-
175
- def _validate_demote_meta(meta: dict) -> None:
176
- missing = [f for f in _DEMOTE_META_REQUIRED if f not in meta]
177
- if missing:
178
- raise ScopeAuditLogError(
179
- f"demote_meta missing required field(s): {missing}"
180
- )
181
-
182
- was_promoted = meta["was_promoted"]
183
- if not isinstance(was_promoted, bool):
184
- raise ScopeAuditLogError(
185
- f"demote_meta.was_promoted must be bool, got {was_promoted!r}"
186
- )
187
-
188
- opdid = meta["original_promotion_decision_id"]
189
- if opdid is not None and (
190
- not isinstance(opdid, str) or not _UUID_RE.match(opdid)
191
- ):
192
- raise ScopeAuditLogError(
193
- f"demote_meta.original_promotion_decision_id must be a UUID string"
194
- f" or null, got {opdid!r}"
195
- )
196
-
197
- days = meta["days_in_pending"]
198
- # bool is a subclass of int -- explicitly reject it.
199
- if (
200
- not isinstance(days, int)
201
- or isinstance(days, bool)
202
- or days < 0
203
- ):
204
- raise ScopeAuditLogError(
205
- f"demote_meta.days_in_pending must be a non-negative int, got {days!r}"
206
- )
207
-
208
- reason = meta["demote_reason"]
209
- if not isinstance(reason, str) or not reason:
210
- raise ScopeAuditLogError(
211
- f"demote_meta.demote_reason must be a non-empty string, got {reason!r}"
212
- )
213
-
214
- demoted_from = meta["demoted_from"]
215
- if not isinstance(demoted_from, str) or not demoted_from:
216
- raise ScopeAuditLogError(
217
- f"demote_meta.demoted_from must be a non-empty string, got {demoted_from!r}"
218
- )
219
-
220
-
221
- # ---------------------------------------------------------------------------
222
- # Locking
223
- # ---------------------------------------------------------------------------
224
-
225
-
226
- @contextmanager
227
- def _append_lock(log_path: Path) -> Iterator[None]:
228
- """Serialise appenders across threads AND processes.
229
-
230
- Mirrors ``candidates_log._append_lock``. Sidecar lock file keeps the
231
- advisory lock orthogonal to the data file so a torn lock-file write
232
- never corrupts the audit trail.
233
- """
234
- lock_path = log_path.parent / (log_path.name + ".lock")
235
- lock_path.parent.mkdir(parents=True, exist_ok=True)
236
- with _thread_lock:
237
- try:
238
- with open(lock_path, "a+b") as fh:
239
- if not lock_path.stat().st_size:
240
- fh.write(b"\0")
241
- fh.flush()
242
- fh.seek(0)
243
- if sys.platform == "win32":
244
- import msvcrt
245
-
246
- acquired = False
247
- deadline = time.monotonic() + 30.0
248
- while True:
249
- try:
250
- msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
251
- acquired = True
252
- break
253
- except OSError:
254
- if time.monotonic() > deadline:
255
- raise
256
- time.sleep(0.02)
257
- try:
258
- yield
259
- finally:
260
- if acquired:
261
- fh.seek(0)
262
- with suppress(OSError):
263
- msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
264
- else:
265
- import fcntl
266
-
267
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
268
- try:
269
- yield
270
- finally:
271
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
272
- finally:
273
- # Remove the sidecar ``<log>.lock`` so a clean append never leaves
274
- # an untracked lock file behind (#1311 discipline). The handle is
275
- # closed by the `with open(...)` block above BEFORE this unlink
276
- # (Windows refuses to delete an open file); held under
277
- # ``_thread_lock`` so the unlink cannot race an in-process
278
- # re-acquire. Best-effort across processes.
279
- with suppress(OSError):
280
- lock_path.unlink()
281
-
282
-
283
- # ---------------------------------------------------------------------------
284
- # I/O
285
- # ---------------------------------------------------------------------------
286
-
287
-
288
- def append(entry: dict, *, log_path: Path | str | None = None) -> str:
289
- """Validate *entry* and atomically append it to the audit log.
290
-
291
- Args:
292
- entry: dict matching the schema documented in the module docstring.
293
- log_path: optional override of the log file path. Production callers
294
- MUST leave this as None and let the caller pass the canonical
295
- path resolved via :func:`canonical_log_path` (this signature
296
- keeps the test hook explicit).
297
-
298
- Returns:
299
- The ``decision_id`` from *entry*.
300
- """
301
- if log_path is None:
302
- raise ScopeAuditLogError(
303
- "append() requires log_path; pass canonical_log_path(project_root)"
304
- )
305
- _validate_entry(entry)
306
- log_file = Path(log_path)
307
- log_file.parent.mkdir(parents=True, exist_ok=True)
308
- line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
309
- with _append_lock(log_file), open(
310
- log_file, "a", encoding="utf-8", newline=""
311
- ) as fh:
312
- fh.write(line + "\n")
313
- fh.flush()
314
- os.fsync(fh.fileno())
315
- return str(entry["decision_id"])
316
-
317
-
318
- def read_all(*, log_path: Path | str | None = None) -> list[dict]:
319
- """Return every well-formed entry in insertion order. Tolerant of
320
- malformed lines (logs a warning, skips them).
321
- """
322
- if log_path is None:
323
- raise ScopeAuditLogError(
324
- "read_all() requires log_path; pass canonical_log_path(project_root)"
325
- )
326
- log_file = Path(log_path)
327
- if not log_file.exists():
328
- return []
329
- out: list[dict] = []
330
- with open(log_file, encoding="utf-8") as fh:
331
- for lineno, raw in enumerate(fh, start=1):
332
- stripped = raw.strip()
333
- if not stripped:
334
- continue
335
- try:
336
- obj = json.loads(stripped)
337
- except json.JSONDecodeError as exc:
338
- LOG.warning(
339
- "scope-lifecycle.jsonl: skipping malformed line %d: %s",
340
- lineno,
341
- exc,
342
- )
343
- continue
344
- if not isinstance(obj, dict):
345
- LOG.warning(
346
- "scope-lifecycle.jsonl: skipping non-object on line %d",
347
- lineno,
348
- )
349
- continue
350
- out.append(obj)
351
- return out
352
-
353
-
354
- def find_by_path(
355
- vbrief_path: str, *, log_path: Path | str | None = None
356
- ) -> list[dict]:
357
- """Return every entry matching ``vbrief_path`` (string equality).
358
-
359
- Path normalisation:
360
- Callers SHOULD pass the canonical form used at write time
361
- (forward-slash project-root-relative). This helper does NOT
362
- re-normalise -- byte-equal match is the contract.
363
- """
364
- return [
365
- e for e in read_all(log_path=log_path) if e.get("vbrief_path") == vbrief_path
366
- ]
367
-
368
-
369
- def latest_for_path(
370
- vbrief_path: str,
371
- action: str | None = None,
372
- *,
373
- log_path: Path | str | None = None,
374
- ) -> dict | None:
375
- """Return the most recent entry for ``vbrief_path``, optionally filtered
376
- by ``action`` (e.g. ``"promote"`` to find the prior promotion).
377
-
378
- Sort key is the entry's ``timestamp``. ISO-8601 UTC strings sort
379
- lexicographically in chronological order.
380
- """
381
- rows = find_by_path(vbrief_path, log_path=log_path)
382
- if action is not None:
383
- rows = [r for r in rows if r.get("action") == action]
384
- if not rows:
385
- return None
386
- rows.sort(key=lambda r: r.get("timestamp", ""))
387
- return rows[-1]