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