@deftai/directive-content 0.58.0 → 0.60.0

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