@deftai/directive-content 0.59.0 → 0.60.0

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