@deftai/directive-content 0.55.2 → 0.56.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 (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,432 @@
1
+ """Append-only audit log for triage decisions (#845 Story 2).
2
+
3
+ Public surface:
4
+ append(entry: dict) -> str # returns decision_id
5
+ read_all(repo: str | None = None) -> list[dict]
6
+ find_by_issue(issue_number: int, repo: str) -> list[dict]
7
+ latest_decision(issue_number: int, repo: str) -> dict | None
8
+
9
+ Storage:
10
+ vbrief/.eval/candidates.jsonl -- one JSON object per line, UTF-8.
11
+ Parent directory is created on first append.
12
+
13
+ Concurrency:
14
+ - Cross-process safety: an advisory lock is held on a sidecar
15
+ ``candidates.jsonl.lock`` file via ``msvcrt.locking`` on Windows and
16
+ ``fcntl.flock`` on POSIX while the writer appends a single line.
17
+ - In-process thread safety: a module-level ``threading.Lock`` serialises
18
+ appends from threads in the same Python process so the line-level
19
+ atomicity holds even when the OS-level byte-range lock would otherwise
20
+ be granted to multiple file descriptors held by the same process.
21
+
22
+ Validation:
23
+ Every dict passed to :func:`append` is validated against the constraints
24
+ in ``vbrief/schemas/candidates.schema.json`` (the FROZEN interface
25
+ contract for downstream agents A3, A4, A6). The validator is hand-rolled
26
+ so this module has no third-party dependency footprint -- the schema
27
+ file remains the canonical reference.
28
+
29
+ Tolerance:
30
+ :func:`read_all` tolerates malformed JSON lines: it logs a warning and
31
+ skips them rather than raising. A partially-written tail from a crashed
32
+ appender must not brick the audit trail for downstream readers.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import logging
39
+ import os
40
+ import re
41
+ import sys
42
+ import threading
43
+ import time
44
+ import uuid
45
+ from collections.abc import Iterator
46
+ from contextlib import contextmanager, suppress
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ LOG = logging.getLogger(__name__)
51
+
52
+ # Canonical default storage location, resolved relative to the repo root.
53
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
54
+
55
+ from _content_root import content_root # noqa: E402
56
+
57
+ REPO_ROOT = Path(__file__).resolve().parent.parent
58
+ # The .eval/ runtime log stays at the root vbrief/ (repo-dev); only the shipped
59
+ # schemas moved under content/ in the #1875 C1 flatten dual-context.
60
+ DEFAULT_LOG_PATH = REPO_ROOT / "vbrief" / ".eval" / "candidates.jsonl"
61
+ SCHEMA_PATH = content_root(REPO_ROOT) / "vbrief" / "schemas" / "candidates.schema.json"
62
+
63
+ # Frozen vocabulary mirrored from candidates.schema.json. Keep in lockstep.
64
+ # ``resume-eligible`` (#1123 / D3) is appended by the resume-condition
65
+ # evaluator when a prior ``defer`` entry's ``resume_on`` fires; it carries
66
+ # ``prior_decision_id`` referencing the defer.
67
+ _VALID_DECISIONS: frozenset[str] = frozenset(
68
+ {
69
+ "accept",
70
+ "reject",
71
+ "defer",
72
+ "needs-ac",
73
+ "mark-duplicate",
74
+ "reset",
75
+ "resume-eligible",
76
+ }
77
+ )
78
+ #: Decisions that require ``prior_decision_id`` -- ``reset`` (rollback)
79
+ #: and ``resume-eligible`` (D3 evaluator marker referencing the defer).
80
+ _PRIOR_REQUIRED_DECISIONS: frozenset[str] = frozenset({"reset", "resume-eligible"})
81
+ _REQUIRED_FIELDS: tuple[str, ...] = (
82
+ "decision_id",
83
+ "timestamp",
84
+ "repo",
85
+ "issue_number",
86
+ "decision",
87
+ "actor",
88
+ )
89
+ _OPTIONAL_FIELDS: tuple[str, ...] = (
90
+ "reason",
91
+ "resume_on",
92
+ "linked_to",
93
+ "prior_decision_id",
94
+ )
95
+ _ALLOWED_FIELDS: frozenset[str] = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS)
96
+
97
+ _UUID_RE = re.compile(
98
+ 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}$"
99
+ )
100
+ _REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
101
+ # UTC-only on purpose: ``latest_decision`` sorts entries by lexicographic
102
+ # timestamp comparison, which is correct only when every timestamp uses the
103
+ # canonical ``Z`` (UTC) suffix. An offset like ``+05:30`` would represent the
104
+ # same instant as a Z-suffixed timestamp at a different wall-clock string and
105
+ # silently invert the chronological order (Greptile #876 P1).
106
+ _ISO8601_RE = re.compile(
107
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$"
108
+ )
109
+
110
+ _thread_lock = threading.Lock()
111
+
112
+
113
+ class CandidatesLogError(ValueError):
114
+ """Raised when an entry passed to :func:`append` fails schema validation."""
115
+
116
+
117
+ def _validate_entry(entry: Any) -> None:
118
+ """Hand-rolled mirror of ``vbrief/schemas/candidates.schema.json``.
119
+
120
+ Raises :class:`CandidatesLogError` with a human-readable message on the
121
+ first violation encountered. Order-of-checks matches the schema (required
122
+ presence -> type -> pattern/enum -> conditional dependencies) so error
123
+ messages cite the most upstream violation.
124
+ """
125
+ if not isinstance(entry, dict):
126
+ raise CandidatesLogError(
127
+ f"entry must be a dict, got {type(entry).__name__}"
128
+ )
129
+
130
+ missing = [f for f in _REQUIRED_FIELDS if f not in entry]
131
+ if missing:
132
+ raise CandidatesLogError(
133
+ f"entry missing required field(s): {missing}"
134
+ )
135
+
136
+ extras = sorted(set(entry.keys()) - _ALLOWED_FIELDS)
137
+ if extras:
138
+ raise CandidatesLogError(f"entry has unknown field(s): {extras}")
139
+
140
+ decision_id = entry["decision_id"]
141
+ if not isinstance(decision_id, str) or not _UUID_RE.match(decision_id):
142
+ raise CandidatesLogError(
143
+ f"decision_id must be a UUID string, got {decision_id!r}"
144
+ )
145
+
146
+ timestamp = entry["timestamp"]
147
+ if not isinstance(timestamp, str) or not _ISO8601_RE.match(timestamp):
148
+ raise CandidatesLogError(
149
+ f"timestamp must be ISO-8601 UTC with Z suffix "
150
+ f"(e.g. 2026-05-03T16:32:54Z), got {timestamp!r}"
151
+ )
152
+
153
+ repo = entry["repo"]
154
+ if not isinstance(repo, str) or not _REPO_RE.match(repo):
155
+ raise CandidatesLogError(
156
+ f"repo must match 'owner/name', got {repo!r}"
157
+ )
158
+
159
+ issue_number = entry["issue_number"]
160
+ # bool is a subclass of int -- explicitly reject it.
161
+ if (
162
+ not isinstance(issue_number, int)
163
+ or isinstance(issue_number, bool)
164
+ or issue_number < 1
165
+ ):
166
+ raise CandidatesLogError(
167
+ f"issue_number must be a positive int, got {issue_number!r}"
168
+ )
169
+
170
+ decision = entry["decision"]
171
+ if decision not in _VALID_DECISIONS:
172
+ raise CandidatesLogError(
173
+ f"decision must be one of {sorted(_VALID_DECISIONS)}, "
174
+ f"got {decision!r}"
175
+ )
176
+
177
+ actor = entry["actor"]
178
+ if not isinstance(actor, str) or not actor:
179
+ raise CandidatesLogError(
180
+ f"actor must be a non-empty string, got {actor!r}"
181
+ )
182
+
183
+ if "reason" in entry and not isinstance(entry["reason"], str):
184
+ raise CandidatesLogError(
185
+ f"reason must be a string, got "
186
+ f"{type(entry['reason']).__name__}"
187
+ )
188
+
189
+ if "resume_on" in entry:
190
+ resume_on = entry["resume_on"]
191
+ if not isinstance(resume_on, str) or not resume_on:
192
+ raise CandidatesLogError(
193
+ f"resume_on must be a non-empty string, got {resume_on!r}"
194
+ )
195
+
196
+ # Conditional fields: linked_to is required for mark-duplicate and forbidden
197
+ # otherwise; prior_decision_id is required for reset and forbidden otherwise.
198
+ if decision == "mark-duplicate":
199
+ if "linked_to" not in entry:
200
+ raise CandidatesLogError(
201
+ "decision 'mark-duplicate' requires 'linked_to'"
202
+ )
203
+ linked_to = entry["linked_to"]
204
+ if (
205
+ not isinstance(linked_to, int)
206
+ or isinstance(linked_to, bool)
207
+ or linked_to < 1
208
+ ):
209
+ raise CandidatesLogError(
210
+ f"linked_to must be a positive int, got {linked_to!r}"
211
+ )
212
+ elif "linked_to" in entry:
213
+ raise CandidatesLogError(
214
+ "'linked_to' is only valid for decision='mark-duplicate'"
215
+ )
216
+
217
+ if decision in _PRIOR_REQUIRED_DECISIONS:
218
+ if "prior_decision_id" not in entry:
219
+ raise CandidatesLogError(
220
+ f"decision {decision!r} requires 'prior_decision_id'"
221
+ )
222
+ pid = entry["prior_decision_id"]
223
+ if not isinstance(pid, str) or not _UUID_RE.match(pid):
224
+ raise CandidatesLogError(
225
+ f"prior_decision_id must be a UUID string, got {pid!r}"
226
+ )
227
+ elif "prior_decision_id" in entry:
228
+ raise CandidatesLogError(
229
+ "'prior_decision_id' is only valid for decision in "
230
+ f"{sorted(_PRIOR_REQUIRED_DECISIONS)}"
231
+ )
232
+
233
+
234
+ @contextmanager
235
+ def _append_lock(log_path: Path) -> Iterator[None]:
236
+ """Serialise appenders across threads AND processes.
237
+
238
+ Acquires the module-level :data:`_thread_lock` first to serialise
239
+ in-process callers, then opens a sidecar ``<log>.lock`` file and takes
240
+ an exclusive byte-range lock via ``msvcrt`` (Windows) or ``fcntl``
241
+ (POSIX). The sidecar pattern keeps the lock orthogonal to the data
242
+ file so a torn lock-file write never affects the audit trail.
243
+ """
244
+ lock_path = log_path.parent / (log_path.name + ".lock")
245
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
246
+ # ``a+b`` opens for read+write+create without truncating -- needed because
247
+ # msvcrt.locking requires the byte range to exist.
248
+ with _thread_lock:
249
+ try:
250
+ with open(lock_path, "a+b") as fh:
251
+ if not lock_path.stat().st_size:
252
+ fh.write(b"\0")
253
+ fh.flush()
254
+ fh.seek(0)
255
+ if sys.platform == "win32":
256
+ import msvcrt
257
+
258
+ # Spin on LK_NBLCK -- the LK_LOCK retry loop is fixed at 10x
259
+ # 1s and would block the test suite on bursty contention.
260
+ # The acquire spin is INTENTIONALLY outside the post-acquire
261
+ # try/finally so a deadline-driven raise does NOT trigger
262
+ # the release path on a never-acquired lock; the explicit
263
+ # ``acquired`` flag makes that invariant load-bearing for
264
+ # future readers (Slizard #876 P2).
265
+ acquired = False
266
+ deadline = time.monotonic() + 30.0
267
+ while True:
268
+ try:
269
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
270
+ acquired = True
271
+ break
272
+ except OSError:
273
+ if time.monotonic() > deadline:
274
+ raise
275
+ time.sleep(0.02)
276
+ try:
277
+ yield
278
+ finally:
279
+ if acquired:
280
+ fh.seek(0)
281
+ # Best-effort release: the lock may already be gone
282
+ # if the process is mid-shutdown; not an error.
283
+ with suppress(OSError):
284
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
285
+ else:
286
+ import fcntl
287
+
288
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
289
+ try:
290
+ yield
291
+ finally:
292
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
293
+ finally:
294
+ # Remove the sidecar ``<log>.lock`` so a clean append never leaves
295
+ # an untracked lock file behind (#1311 discipline). The handle is
296
+ # closed by the `with open(...)` block above BEFORE this unlink
297
+ # (Windows refuses to delete an open file); held under
298
+ # ``_thread_lock`` so the unlink cannot race an in-process
299
+ # re-acquire. Best-effort across processes.
300
+ with suppress(OSError):
301
+ lock_path.unlink()
302
+
303
+
304
+ def _resolve_path(path: Path | str | None) -> Path:
305
+ return Path(path) if path is not None else DEFAULT_LOG_PATH
306
+
307
+
308
+ def append(entry: dict, *, path: Path | str | None = None) -> str:
309
+ """Validate ``entry`` and atomically append it to the audit log.
310
+
311
+ Args:
312
+ entry: A dict matching ``vbrief/schemas/candidates.schema.json``.
313
+ The caller is responsible for generating ``decision_id`` (a
314
+ UUID4 string) and ``timestamp`` (ISO-8601 UTC). The module
315
+ does not silently fill these in -- callers MUST be explicit so
316
+ tests and replays are deterministic.
317
+ path: Optional override of the log file path. Used by tests to
318
+ redirect writes to a tmp directory; in production callers
319
+ this MUST be left as None to hit the canonical location.
320
+
321
+ Returns:
322
+ The validated ``decision_id`` string from ``entry``.
323
+
324
+ Raises:
325
+ CandidatesLogError: if ``entry`` fails validation. No bytes are
326
+ written to disk in this case.
327
+ """
328
+ _validate_entry(entry)
329
+ log_path = _resolve_path(path)
330
+ log_path.parent.mkdir(parents=True, exist_ok=True)
331
+ # sort_keys for stable on-disk ordering; ensure_ascii=False preserves
332
+ # non-ASCII actor/reason strings as UTF-8 rather than \uXXXX escapes.
333
+ line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
334
+ with _append_lock(log_path), open(
335
+ log_path, "a", encoding="utf-8", newline=""
336
+ ) as fh:
337
+ fh.write(line + "\n")
338
+ fh.flush()
339
+ os.fsync(fh.fileno())
340
+ return entry["decision_id"]
341
+
342
+
343
+ def read_all(
344
+ repo: str | None = None, *, path: Path | str | None = None
345
+ ) -> list[dict]:
346
+ """Return every well-formed entry in chronological insertion order.
347
+
348
+ Args:
349
+ repo: Optional ``owner/name`` filter; entries with a different
350
+ ``repo`` are excluded.
351
+ path: Optional log path override (test hook).
352
+
353
+ Returns:
354
+ A list of dicts -- never None. An empty list is returned both when
355
+ the file does not exist and when every line is malformed.
356
+ """
357
+ log_path = _resolve_path(path)
358
+ if not log_path.exists():
359
+ return []
360
+ out: list[dict] = []
361
+ with open(log_path, encoding="utf-8") as fh:
362
+ for lineno, raw in enumerate(fh, start=1):
363
+ stripped = raw.strip()
364
+ if not stripped:
365
+ continue
366
+ try:
367
+ obj = json.loads(stripped)
368
+ except json.JSONDecodeError as exc:
369
+ LOG.warning(
370
+ "candidates.jsonl: skipping malformed JSON on line %d: %s",
371
+ lineno,
372
+ exc,
373
+ )
374
+ continue
375
+ if not isinstance(obj, dict):
376
+ LOG.warning(
377
+ "candidates.jsonl: skipping non-object entry on line %d "
378
+ "(got %s)",
379
+ lineno,
380
+ type(obj).__name__,
381
+ )
382
+ continue
383
+ if repo is not None and obj.get("repo") != repo:
384
+ continue
385
+ out.append(obj)
386
+ return out
387
+
388
+
389
+ def find_by_issue(
390
+ issue_number: int,
391
+ repo: str,
392
+ *,
393
+ path: Path | str | None = None,
394
+ ) -> list[dict]:
395
+ """Return every entry for ``(repo, issue_number)`` in insertion order."""
396
+ return [
397
+ e
398
+ for e in read_all(repo=repo, path=path)
399
+ if e.get("issue_number") == issue_number
400
+ ]
401
+
402
+
403
+ def latest_decision(
404
+ issue_number: int,
405
+ repo: str,
406
+ *,
407
+ path: Path | str | None = None,
408
+ ) -> dict | None:
409
+ """Return the most recent decision for ``(repo, issue_number)``.
410
+
411
+ Sort key is the entry's ``timestamp`` field. ISO-8601 strings sort
412
+ lexicographically in chronological order so a string sort is correct
413
+ for any compliant timestamp produced by :func:`append`.
414
+
415
+ Returns:
416
+ The latest dict, or None if no decisions exist for the issue.
417
+ """
418
+ rows = find_by_issue(issue_number, repo, path=path)
419
+ if not rows:
420
+ return None
421
+ rows.sort(key=lambda r: r.get("timestamp", ""))
422
+ return rows[-1]
423
+
424
+
425
+ def new_decision_id() -> str:
426
+ """Helper: return a fresh UUID4 string for use as ``decision_id``.
427
+
428
+ Provided so callers (Story 3 triage actions) do not have to pull in
429
+ ``uuid`` directly and so a future swap to UUID7 (time-ordered) is a
430
+ single-file change.
431
+ """
432
+ return str(uuid.uuid4())