@deftai/directive-content 0.59.0 → 0.61.0

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