@deftai/directive-content 0.59.0 → 0.60.0

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