@deftai/directive-content 0.55.2 → 0.56.1

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