@deftai/directive-content 0.55.2 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,706 @@
1
+ """resume_conditions.py -- defer ``--resume-on`` grammar + evaluator (#1123 / D3 of #1119).
2
+
3
+ Public surface
4
+ --------------
5
+
6
+ * :func:`parse` -- structurally validate a resume-condition expression and
7
+ return an :class:`Expression` AST. Raises :class:`ResumeGrammarError`
8
+ with a human-readable message on the first malformed atom / composition.
9
+ * :func:`evaluate` -- evaluate a parsed AST against a :class:`ResumeContext`
10
+ snapshot and return a bool.
11
+ * :func:`build_context` -- derive a :class:`ResumeContext` from the
12
+ framework's on-disk state (unified ``.deft-cache/github-issue/`` cache
13
+ for closed/merged refs, ``vbrief/pending/`` for the count, ``today`` for
14
+ the date comparison). Pure-stdlib; no live ``gh`` calls.
15
+ * :func:`evaluate_resume_eligibility` -- the orchestration entry point
16
+ consumed by ``task triage:audit --evaluate-resume`` and
17
+ ``task triage:refresh-active``. Walks the audit log, identifies open
18
+ ``defer`` entries with a non-null ``resume_on``, evaluates each against
19
+ the provided context, and APPENDS a new ``resume-eligible`` audit entry
20
+ (with ``prior_decision_id`` pointing at the original ``defer``) for
21
+ each condition that fires. Idempotent: re-running the evaluation does
22
+ NOT duplicate ``resume-eligible`` entries.
23
+
24
+ Grammar (minimal viable v1, per issue #1123)
25
+ -------------------------------------------
26
+
27
+ Atomic conditions::
28
+
29
+ ref:closed:#N -- fires when issue/PR N is closed in the cache
30
+ ref:merged:#N -- fires when PR N is merged in the cache
31
+ date:>=YYYY-MM-DD -- fires when current date is at or past target
32
+ pending-count:>=N -- fires when len(vbrief/pending/) >= N
33
+ pending-count:<=N -- fires when len(vbrief/pending/) <= N
34
+ slice-wave-ready:<slice_id>:<wave>
35
+ -- fires when every child of <slice_id>
36
+ in an earlier wave is closed (#1132 /
37
+ D13). ``<slice_id>`` is a UUID; ``<wave>``
38
+ is a positive int. Sourced from
39
+ vbrief/.eval/slices.jsonl.
40
+
41
+ Top-level composition (no nested parens / NOT in v1)::
42
+
43
+ <atomic> AND <atomic> -- fires when both atomics fire
44
+ <atomic> OR <atomic> -- fires when either atomic fires
45
+
46
+ Anything else is a grammar error and rejected at write time by
47
+ :func:`scripts.triage_actions.defer` and at evaluation time by
48
+ :func:`parse`. Whitespace around ``AND`` / ``OR`` is required; the
49
+ parser does not collapse arbitrary spacing into the operator token.
50
+
51
+ Design notes
52
+ ------------
53
+
54
+ * The framework MUST NOT auto-un-defer. ``resume-eligible`` is a marker
55
+ that surfaces the item at the top of D11's ``[RESUME]`` group; the
56
+ operator still decides whether to re-triage with current data.
57
+ * Closed / merged signals come from the existing unified-cache
58
+ ``state`` field (``"open" | "closed"``). The cache writer is owned
59
+ by ``scripts/cache.py`` (#883 Story 2); this module is read-only.
60
+ * ``ref:closed:#N`` fires for BOTH issues and PRs that have transitioned
61
+ to ``closed``; ``ref:merged:#N`` is stricter and requires the cached
62
+ payload to carry ``"merged": true`` (PRs only). A PR that is closed
63
+ without merging fires ``ref:closed`` but NOT ``ref:merged``.
64
+ * Re-evaluation idempotency is enforced by scanning prior audit entries:
65
+ if a ``resume-eligible`` row already exists for the defer's
66
+ ``decision_id``, no new row is appended. A subsequent ``reset`` /
67
+ re-defer wipes the marker and allows the next evaluation pass to
68
+ surface the item again.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import contextlib
74
+ import json
75
+ import logging
76
+ import re
77
+ import sys
78
+ from collections.abc import Iterable
79
+ from dataclasses import dataclass, field
80
+ from datetime import UTC, date, datetime
81
+ from pathlib import Path
82
+ from typing import Any, cast
83
+
84
+ # Make sibling scripts importable when invoked as ``python scripts/resume_conditions.py``.
85
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
86
+
87
+ # Optional dependency: ``candidates_log`` is the canonical append-only
88
+ # audit-log writer (#845 Story 2). Guarded so this module imports cleanly
89
+ # on a checkout that has not yet rebased onto Story 2 (tests substitute a
90
+ # fake via ``monkeypatch.setattr``).
91
+ try: # pragma: no cover -- exercised once #845 Story 2 lands.
92
+ import candidates_log # type: ignore[import-not-found]
93
+ except ImportError: # pragma: no cover
94
+ candidates_log = None # type: ignore[assignment]
95
+
96
+ # Optional dependency: ``slice_record`` is the slicing-cohort writer
97
+ # introduced alongside this grammar extension (#1132 / D13). The
98
+ # ``slice-wave-ready:<slice_id>:<wave>`` atomic reads slices.jsonl via
99
+ # this module. Guarded so the grammar still loads on pre-D13 checkouts.
100
+ try: # pragma: no cover -- exercised once #1132 lands.
101
+ import slice_record # type: ignore[import-not-found]
102
+ except ImportError: # pragma: no cover
103
+ slice_record = None # type: ignore[assignment]
104
+
105
+ LOG = logging.getLogger(__name__)
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Public constants
109
+ # ---------------------------------------------------------------------------
110
+
111
+ #: Audit-log decision tag emitted when a resume condition fires. Mirrors
112
+ #: the addition to ``vbrief/schemas/candidates.schema.json``'s ``decision``
113
+ #: enum (the schema and this constant MUST stay in lockstep).
114
+ RESUME_ELIGIBLE_DECISION: str = "resume-eligible"
115
+
116
+ #: Audit-log actor tag for evaluator-driven appends.
117
+ EVALUATOR_ACTOR: str = "agent:resume-evaluator"
118
+
119
+ #: Filesystem-relative location of the unified content cache root.
120
+ CACHE_DIR_NAME: str = ".deft-cache"
121
+
122
+ #: Cache source layer the resume evaluator reads.
123
+ CACHE_SOURCE_GITHUB_ISSUE: str = "github-issue"
124
+
125
+ #: vBRIEF lifecycle folder counted by ``pending-count:`` atoms. Mirrors
126
+ #: the D4 (#1124) cap target; D3 uses ``pending/`` ONLY (NOT ``active/``)
127
+ #: because the issue body's example
128
+ #: ``ref:closed:#1121 AND pending-count:>=18`` describes the operator's
129
+ #: "should I revisit this defer now that pending has accumulated?" intent,
130
+ #: which is about the proposed-but-not-yet-active backlog.
131
+ PENDING_LIFECYCLE_DIR: str = "pending"
132
+
133
+
134
+ class ResumeGrammarError(ValueError):
135
+ """Raised when a resume-condition expression fails to parse."""
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # AST
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Atomic:
145
+ """One atomic resume condition.
146
+
147
+ ``kind`` is one of:
148
+
149
+ * ``"ref-closed"`` -- ``value`` is the int issue / PR number.
150
+ * ``"ref-merged"`` -- ``value`` is the int PR number.
151
+ * ``"date-ge"`` -- ``value`` is a :class:`datetime.date`.
152
+ * ``"pending-count-ge"`` -- ``value`` is the int threshold.
153
+ * ``"pending-count-le"`` -- ``value`` is the int threshold.
154
+ * ``"slice-wave-ready"`` -- ``value`` is the int wave threshold;
155
+ :attr:`slice_id` carries the cohort identifier (#1132 / D13).
156
+
157
+ The dataclass is intentionally simple -- the renderer round-trips
158
+ via :attr:`raw` so the original operator-supplied text is preserved
159
+ in error messages and audit-log debugging.
160
+ """
161
+
162
+ kind: str
163
+ value: int | date
164
+ raw: str
165
+ #: Slice identifier carried by ``slice-wave-ready`` atoms (#1132). UUID
166
+ #: string; empty for every other atomic kind.
167
+ slice_id: str = ""
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class Expression:
172
+ """Top-level resume-condition expression.
173
+
174
+ ``op`` is one of ``"ATOM" | "AND" | "OR"``. For ``"ATOM"``, ``left``
175
+ holds the only atomic and ``right`` is ``None``. For ``"AND"`` /
176
+ ``"OR"``, both ``left`` and ``right`` are :class:`Atomic` instances
177
+ (nesting is intentionally not supported in v1 per the issue body).
178
+ """
179
+
180
+ op: str
181
+ left: Atomic
182
+ right: Atomic | None = None
183
+ raw: str = ""
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class ResumeContext:
188
+ """Snapshot of on-disk state the evaluator compares atomic conditions against.
189
+
190
+ Attributes:
191
+ today: Current calendar date in UTC. Compared against
192
+ ``date:>=YYYY-MM-DD`` atoms.
193
+ closed_refs: Set of issue / PR numbers whose cached ``state``
194
+ is ``"closed"``.
195
+ merged_refs: Set of PR numbers whose cached payload carries
196
+ ``"merged": true``. A closed-without-merge PR is in
197
+ ``closed_refs`` but NOT in ``merged_refs``.
198
+ pending_count: Number of ``*.vbrief.json`` files in
199
+ ``vbrief/pending/``.
200
+ slices: Cohort records from ``vbrief/.eval/slices.jsonl`` (#1132 /
201
+ D13). Consulted by ``slice-wave-ready:<slice_id>:<wave>``
202
+ atoms; empty tuple for back-compat with pre-D13 callers.
203
+ """
204
+
205
+ today: date
206
+ closed_refs: frozenset[int] = field(default_factory=frozenset)
207
+ merged_refs: frozenset[int] = field(default_factory=frozenset)
208
+ pending_count: int = 0
209
+ slices: tuple[dict[str, Any], ...] = field(default_factory=tuple)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Parser
214
+ # ---------------------------------------------------------------------------
215
+
216
+ _REF_CLOSED_RE = re.compile(r"^ref:closed:#(\d+)$")
217
+ _REF_MERGED_RE = re.compile(r"^ref:merged:#(\d+)$")
218
+ _DATE_GE_RE = re.compile(r"^date:>=(\d{4}-\d{2}-\d{2})$")
219
+ _PENDING_GE_RE = re.compile(r"^pending-count:>=(\d+)$")
220
+ _PENDING_LE_RE = re.compile(r"^pending-count:<=(\d+)$")
221
+ # slice-wave-ready:<uuid>:<wave>. UUID regex matches any RFC 4122 variant
222
+ # (any version). Wave is a positive int.
223
+ _SLICE_WAVE_READY_RE = re.compile(
224
+ r"^slice-wave-ready:"
225
+ 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})"
226
+ r":(\d+)$"
227
+ )
228
+
229
+ # AND / OR splitter -- whitespace-required so a value like "ANDREW" in a
230
+ # free-text field could never be misparsed as a composition operator. The
231
+ # split is non-greedy on the first occurrence; nested forms (more than one
232
+ # operator at the top level) are rejected explicitly by :func:`parse`.
233
+ _COMPOSITION_RE = re.compile(r"\s+(AND|OR)\s+")
234
+
235
+
236
+ def _parse_atomic(raw: str) -> Atomic:
237
+ """Parse a single atomic-condition string. Raises :class:`ResumeGrammarError`."""
238
+ text = raw.strip()
239
+ if not text:
240
+ raise ResumeGrammarError("empty atomic condition")
241
+
242
+ if (m := _REF_CLOSED_RE.match(text)) is not None:
243
+ return Atomic(kind="ref-closed", value=int(m.group(1)), raw=text)
244
+ if (m := _REF_MERGED_RE.match(text)) is not None:
245
+ return Atomic(kind="ref-merged", value=int(m.group(1)), raw=text)
246
+ if (m := _DATE_GE_RE.match(text)) is not None:
247
+ try:
248
+ parsed = date.fromisoformat(m.group(1))
249
+ except ValueError as exc:
250
+ raise ResumeGrammarError(
251
+ f"invalid date in {text!r}: {exc}"
252
+ ) from exc
253
+ return Atomic(kind="date-ge", value=parsed, raw=text)
254
+ if (m := _PENDING_GE_RE.match(text)) is not None:
255
+ return Atomic(kind="pending-count-ge", value=int(m.group(1)), raw=text)
256
+ if (m := _PENDING_LE_RE.match(text)) is not None:
257
+ return Atomic(kind="pending-count-le", value=int(m.group(1)), raw=text)
258
+ if (m := _SLICE_WAVE_READY_RE.match(text)) is not None:
259
+ wave = int(m.group(2))
260
+ if wave < 1:
261
+ raise ResumeGrammarError(
262
+ f"slice-wave-ready wave must be a positive int, got {wave}"
263
+ )
264
+ return Atomic(
265
+ kind="slice-wave-ready",
266
+ value=wave,
267
+ raw=text,
268
+ slice_id=m.group(1).lower(),
269
+ )
270
+
271
+ raise ResumeGrammarError(
272
+ f"unrecognised atomic condition {text!r}; "
273
+ "expected one of: ref:closed:#N, ref:merged:#N, date:>=YYYY-MM-DD, "
274
+ "pending-count:>=N, pending-count:<=N, "
275
+ "slice-wave-ready:<slice_id>:<wave>"
276
+ )
277
+
278
+
279
+ def parse(expr: str) -> Expression:
280
+ """Parse ``expr`` and return an :class:`Expression` AST.
281
+
282
+ Composition rules (v1):
283
+
284
+ * Whitespace-surrounded ``AND`` or ``OR`` joins exactly two atomics.
285
+ * Mixing operators (``A AND B OR C``) is rejected -- v1 does not
286
+ define operator precedence; the operator MUST be uniform.
287
+ * More than one operator at the top level (``A AND B AND C``) is
288
+ rejected -- nested / multi-arity composition is deferred to v2.
289
+
290
+ Raises:
291
+ ResumeGrammarError: with an actionable message on any violation.
292
+ """
293
+ if not isinstance(expr, str):
294
+ raise ResumeGrammarError(
295
+ f"resume_on must be a string, got {type(expr).__name__}"
296
+ )
297
+ text = expr.strip()
298
+ if not text:
299
+ raise ResumeGrammarError("resume_on must be a non-empty string")
300
+
301
+ parts = _COMPOSITION_RE.split(text)
302
+ if len(parts) == 1:
303
+ atom = _parse_atomic(parts[0])
304
+ return Expression(op="ATOM", left=atom, right=None, raw=text)
305
+ if len(parts) == 3:
306
+ left_raw, op, right_raw = parts
307
+ if op not in {"AND", "OR"}: # pragma: no cover -- regex guards this
308
+ raise ResumeGrammarError(
309
+ f"unknown composition operator {op!r}; expected AND or OR"
310
+ )
311
+ left = _parse_atomic(left_raw)
312
+ right = _parse_atomic(right_raw)
313
+ return Expression(op=op, left=left, right=right, raw=text)
314
+ # 5+ parts means at least two operators (regex split yields
315
+ # ``[lhs, op, mid, op, rhs, ...]``). v1 forbids this.
316
+ raise ResumeGrammarError(
317
+ f"resume_on supports a single top-level AND/OR in v1; got {text!r}"
318
+ )
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Evaluator
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def _eval_atomic(atom: Atomic, ctx: ResumeContext) -> bool:
327
+ # ``atom.value`` is typed as ``int | date`` to accommodate both shapes the
328
+ # parser produces; each branch below knows the actual concrete type from
329
+ # the ``kind`` discriminator and casts via ``cast`` so the static type
330
+ # checker can see the narrowing.
331
+ if atom.kind == "ref-closed":
332
+ return cast(int, atom.value) in ctx.closed_refs
333
+ if atom.kind == "ref-merged":
334
+ return cast(int, atom.value) in ctx.merged_refs
335
+ if atom.kind == "date-ge":
336
+ return ctx.today >= cast(date, atom.value)
337
+ if atom.kind == "pending-count-ge":
338
+ return ctx.pending_count >= cast(int, atom.value)
339
+ if atom.kind == "pending-count-le":
340
+ return ctx.pending_count <= cast(int, atom.value)
341
+ if atom.kind == "slice-wave-ready":
342
+ wave = cast(int, atom.value)
343
+ return _slice_wave_ready(ctx, atom.slice_id, wave)
344
+ # Unreachable: parse() rejects unknown kinds. Defensive: a future
345
+ # additive atomic that lands without an evaluator branch should be a
346
+ # loud failure, not a silent ``False``.
347
+ raise ResumeGrammarError( # pragma: no cover -- defensive
348
+ f"evaluator missing branch for atomic kind {atom.kind!r}"
349
+ )
350
+
351
+
352
+ def _slice_wave_ready(ctx: ResumeContext, slice_id: str, wave: int) -> bool:
353
+ """Return True when every child of ``slice_id`` in an earlier wave is closed.
354
+
355
+ Semantics (per #1132 issue body):
356
+
357
+ * Looks up the slice record by ``slice_id`` in ``ctx.slices``.
358
+ * Considers only children whose ``wave`` is < ``wave``.
359
+ * Fires when EVERY earlier-wave child's number is in
360
+ ``ctx.closed_refs``.
361
+ * If the slice record is absent, or there are no earlier-wave
362
+ children (e.g. ``wave == 1``, which has no Wave-0 to gate on),
363
+ the atomic does NOT fire -- the resume condition is meaningless
364
+ and should be revised by the operator rather than silently
365
+ passing.
366
+ """
367
+ sid_norm = slice_id.lower()
368
+ record: dict[str, Any] | None = None
369
+ for entry in ctx.slices:
370
+ if not isinstance(entry, dict):
371
+ continue
372
+ candidate = entry.get("slice_id")
373
+ if isinstance(candidate, str) and candidate.lower() == sid_norm:
374
+ record = entry
375
+ break
376
+ if record is None:
377
+ return False
378
+ children = record.get("children")
379
+ if not isinstance(children, list):
380
+ return False
381
+ earlier: list[int] = []
382
+ for child in children:
383
+ if not isinstance(child, dict):
384
+ continue
385
+ cwave = child.get("wave")
386
+ cn = child.get("n")
387
+ if not isinstance(cwave, int) or not isinstance(cn, int):
388
+ continue
389
+ if cwave < wave:
390
+ earlier.append(cn)
391
+ if not earlier:
392
+ return False
393
+ return all(n in ctx.closed_refs for n in earlier)
394
+
395
+
396
+ def evaluate(expr: Expression, ctx: ResumeContext) -> bool:
397
+ """Evaluate ``expr`` against ``ctx`` and return whether the condition fires."""
398
+ if expr.op == "ATOM":
399
+ return _eval_atomic(expr.left, ctx)
400
+ if expr.op == "AND":
401
+ if expr.right is None: # pragma: no cover -- parse guards
402
+ raise ResumeGrammarError("AND expression missing right-hand atom")
403
+ return _eval_atomic(expr.left, ctx) and _eval_atomic(expr.right, ctx)
404
+ if expr.op == "OR":
405
+ if expr.right is None: # pragma: no cover -- parse guards
406
+ raise ResumeGrammarError("OR expression missing right-hand atom")
407
+ return _eval_atomic(expr.left, ctx) or _eval_atomic(expr.right, ctx)
408
+ raise ResumeGrammarError( # pragma: no cover -- defensive
409
+ f"unknown composition op {expr.op!r}"
410
+ )
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # Context builder
415
+ # ---------------------------------------------------------------------------
416
+
417
+
418
+ def _count_pending(project_root: Path) -> int:
419
+ folder = project_root / "vbrief" / PENDING_LIFECYCLE_DIR
420
+ if not folder.is_dir():
421
+ return 0
422
+ return sum(
423
+ 1
424
+ for child in folder.iterdir()
425
+ if child.is_file() and child.name.endswith(".vbrief.json")
426
+ )
427
+
428
+
429
+ def _iter_cached_payloads(
430
+ project_root: Path,
431
+ *,
432
+ cache_root: Path | None = None,
433
+ repo: str | None = None,
434
+ ) -> Iterable[tuple[str, int, dict[str, Any]]]:
435
+ """Yield ``(repo, number, payload)`` for every cached issue/PR.
436
+
437
+ Walks ``<cache>/github-issue/<owner>/<repo>/<N>/raw.json``. The
438
+ ``repo`` filter is optional; when set, restricts to a single
439
+ ``owner/name`` slug (used by tests + the CLI when --repo is passed).
440
+ """
441
+ base = (cache_root or (project_root / CACHE_DIR_NAME)) / CACHE_SOURCE_GITHUB_ISSUE
442
+ if not base.is_dir():
443
+ return
444
+ target_owner: str | None = None
445
+ target_name: str | None = None
446
+ if repo and "/" in repo:
447
+ target_owner, target_name = repo.split("/", 1)
448
+ for owner_dir in base.iterdir():
449
+ if not owner_dir.is_dir():
450
+ continue
451
+ if target_owner is not None and owner_dir.name != target_owner:
452
+ continue
453
+ for repo_dir in owner_dir.iterdir():
454
+ if not repo_dir.is_dir():
455
+ continue
456
+ if target_name is not None and repo_dir.name != target_name:
457
+ continue
458
+ slug = f"{owner_dir.name}/{repo_dir.name}"
459
+ for issue_dir in repo_dir.iterdir():
460
+ if not issue_dir.is_dir() or not issue_dir.name.isdecimal():
461
+ continue
462
+ raw_path = issue_dir / "raw.json"
463
+ if not raw_path.is_file():
464
+ continue
465
+ try:
466
+ payload = json.loads(raw_path.read_text(encoding="utf-8"))
467
+ except (OSError, json.JSONDecodeError):
468
+ continue
469
+ if not isinstance(payload, dict):
470
+ continue
471
+ try:
472
+ n = int(issue_dir.name)
473
+ except ValueError:
474
+ continue
475
+ yield slug, n, payload
476
+
477
+
478
+ def build_context(
479
+ project_root: Path,
480
+ *,
481
+ cache_root: Path | None = None,
482
+ today: date | None = None,
483
+ repo: str | None = None,
484
+ slices_log_path: Path | None = None,
485
+ ) -> ResumeContext:
486
+ """Derive a :class:`ResumeContext` from on-disk state.
487
+
488
+ Pure-stdlib reader -- no live ``gh`` calls. ``today`` defaults to the
489
+ UTC calendar date (so a midnight-boundary cron run on a UTC host
490
+ evaluates ``date:>=`` consistently). ``slices_log_path`` overrides
491
+ the default ``vbrief/.eval/slices.jsonl`` location for tests; the
492
+ canonical path is used otherwise. When :mod:`slice_record` is not
493
+ importable (pre-D13 slim checkout) the slices tuple is empty and
494
+ ``slice-wave-ready`` atoms cannot fire.
495
+ """
496
+ today_resolved = today or datetime.now(UTC).date()
497
+ closed: set[int] = set()
498
+ merged: set[int] = set()
499
+ for _slug, n, payload in _iter_cached_payloads(
500
+ project_root, cache_root=cache_root, repo=repo
501
+ ):
502
+ state = payload.get("state")
503
+ if isinstance(state, str) and state.lower() == "closed":
504
+ closed.add(n)
505
+ # ``merged`` is a PR-only field; ``"mergedAt"`` is the canonical
506
+ # marker emitted by ``gh pr view --json``, but plain GitHub REST
507
+ # uses ``"merged": true``. Accept both so the evaluator works
508
+ # against either cache writer.
509
+ if payload.get("merged") is True or payload.get("mergedAt"):
510
+ merged.add(n)
511
+ slices: tuple[dict[str, Any], ...] = ()
512
+ if slice_record is not None:
513
+ try:
514
+ records = slice_record.read_all(path=slices_log_path)
515
+ except Exception as exc: # noqa: BLE001 -- best-effort; pre-D13 fallback
516
+ LOG.warning("slice_record.read_all failed: %s", exc)
517
+ records = []
518
+ slices = tuple(r for r in records if isinstance(r, dict))
519
+ return ResumeContext(
520
+ today=today_resolved,
521
+ closed_refs=frozenset(closed),
522
+ merged_refs=frozenset(merged),
523
+ pending_count=_count_pending(project_root),
524
+ slices=slices,
525
+ )
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # Evaluator orchestration -- the audit-log writer
530
+ # ---------------------------------------------------------------------------
531
+
532
+
533
+ def _now_iso() -> str:
534
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
535
+
536
+
537
+ def _open_defer_entries(
538
+ entries: list[dict[str, Any]],
539
+ ) -> list[dict[str, Any]]:
540
+ """Return the open ``defer`` entries -- those not yet superseded.
541
+
542
+ An entry is "open" when:
543
+
544
+ * It is a ``defer`` decision with a non-null ``resume_on`` field, AND
545
+ * No later (timestamp >) entry for the same ``(repo, issue_number)``
546
+ has ``decision`` in ``{accept, reject, mark-duplicate, reset,
547
+ resume-eligible}``.
548
+
549
+ The ``resume-eligible`` self-supersession is what guarantees
550
+ idempotency: once we've emitted a ``resume-eligible`` row for a
551
+ defer, the defer is no longer "open" from this function's
552
+ perspective and a re-evaluation skips it.
553
+ """
554
+ # Group by (repo, issue_number) so we can pick the latest entry per
555
+ # issue and decide supersession.
556
+ by_issue: dict[tuple[str, int], list[dict[str, Any]]] = {}
557
+ for entry in entries:
558
+ if not isinstance(entry, dict):
559
+ continue
560
+ repo = entry.get("repo")
561
+ number = entry.get("issue_number")
562
+ if not isinstance(repo, str) or not isinstance(number, int):
563
+ continue
564
+ by_issue.setdefault((repo, number), []).append(entry)
565
+
566
+ superseding = {"accept", "reject", "mark-duplicate", "reset", RESUME_ELIGIBLE_DECISION}
567
+ open_defers: list[dict[str, Any]] = []
568
+ for rows in by_issue.values():
569
+ # Sort by timestamp ascending so the last entry is most recent.
570
+ rows.sort(key=lambda r: str(r.get("timestamp", "")))
571
+ target_defer: dict[str, Any] | None = None
572
+ superseded = False
573
+ for row in rows:
574
+ decision = row.get("decision")
575
+ if decision == "defer":
576
+ target_defer = row
577
+ superseded = False
578
+ elif decision in superseding and target_defer is not None:
579
+ # Same-issue successor wipes the open-defer candidacy.
580
+ # ``reset`` re-opens to untriaged (handled by the
581
+ # decision != "defer" branch -- target_defer stays None
582
+ # until a new defer lands).
583
+ superseded = True
584
+ target_defer = None
585
+ if target_defer is None or superseded:
586
+ continue
587
+ if not target_defer.get("resume_on"):
588
+ continue
589
+ open_defers.append(target_defer)
590
+ return open_defers
591
+
592
+
593
+ def evaluate_resume_eligibility(
594
+ project_root: Path,
595
+ *,
596
+ cache_root: Path | None = None,
597
+ audit_log_path: Path | None = None,
598
+ today: date | None = None,
599
+ repo: str | None = None,
600
+ log_module: Any | None = None,
601
+ new_id: Any | None = None,
602
+ now_iso: Any | None = None,
603
+ ) -> list[dict[str, Any]]:
604
+ """Evaluate every open defer with ``resume_on`` and append firings.
605
+
606
+ Returns the list of newly-appended ``resume-eligible`` entries (may
607
+ be empty). Skipping conditions:
608
+
609
+ * No ``resume_on`` field on the defer -- pre-D3 entries pass through.
610
+ * Condition does not fire against the current :class:`ResumeContext`.
611
+ * A ``resume-eligible`` entry already exists referencing the defer's
612
+ ``decision_id`` -- the marker is idempotent.
613
+
614
+ The ``log_module`` / ``new_id`` / ``now_iso`` hooks let tests inject
615
+ fakes without monkeypatching module-level state. Production callers
616
+ leave them as ``None`` so the canonical ``candidates_log.append``
617
+ seam is used.
618
+ """
619
+ log = log_module if log_module is not None else candidates_log
620
+ if log is None:
621
+ # No audit-log writer available -- nothing to do. Production
622
+ # bootstrap lands the writer; this branch is for slim test
623
+ # checkouts (mirrors the pattern in scripts/triage_actions.py).
624
+ return []
625
+ new_decision_id = new_id or getattr(log, "new_decision_id", None)
626
+ if not callable(new_decision_id): # pragma: no cover -- defensive
627
+ import uuid as _uuid
628
+
629
+ new_decision_id = lambda: str(_uuid.uuid4()) # noqa: E731
630
+ timestamp_fn = now_iso or _now_iso
631
+
632
+ entries = list(log.read_all(repo=repo, path=audit_log_path))
633
+ open_defers = _open_defer_entries(entries)
634
+ if not open_defers:
635
+ return []
636
+
637
+ ctx = build_context(
638
+ project_root, cache_root=cache_root, today=today, repo=repo
639
+ )
640
+
641
+ appended: list[dict[str, Any]] = []
642
+ for defer_entry in open_defers:
643
+ expression_text = defer_entry.get("resume_on")
644
+ if not isinstance(expression_text, str):
645
+ continue
646
+ try:
647
+ ast = parse(expression_text)
648
+ except ResumeGrammarError as exc:
649
+ LOG.warning(
650
+ "skipping defer #%s (%s): malformed resume_on %r (%s)",
651
+ defer_entry.get("issue_number"),
652
+ defer_entry.get("repo"),
653
+ expression_text,
654
+ exc,
655
+ )
656
+ continue
657
+ if not evaluate(ast, ctx):
658
+ continue
659
+ new_entry: dict[str, Any] = {
660
+ "decision_id": str(new_decision_id()),
661
+ "timestamp": timestamp_fn(),
662
+ "repo": str(defer_entry["repo"]),
663
+ "issue_number": int(defer_entry["issue_number"]),
664
+ "decision": RESUME_ELIGIBLE_DECISION,
665
+ "actor": EVALUATOR_ACTOR,
666
+ "prior_decision_id": str(defer_entry["decision_id"]),
667
+ "reason": f"resume_on fired: {expression_text}",
668
+ }
669
+ try:
670
+ log.append(new_entry, path=audit_log_path) if audit_log_path else log.append(new_entry)
671
+ except TypeError:
672
+ # Older test fakes accept (entry) only -- fall back without path kw.
673
+ log.append(new_entry)
674
+ except Exception as exc: # noqa: BLE001 -- best-effort; surface failure
675
+ LOG.warning(
676
+ "candidates_log.append failed for defer #%s: %s",
677
+ defer_entry.get("issue_number"),
678
+ exc,
679
+ )
680
+ continue
681
+ appended.append(new_entry)
682
+ return appended
683
+
684
+
685
+ # ---------------------------------------------------------------------------
686
+ # Best-effort UTF-8 stdout (CLI consumers print expressions verbatim)
687
+ # ---------------------------------------------------------------------------
688
+
689
+ for _stream in (sys.stdout, sys.stderr):
690
+ if hasattr(_stream, "reconfigure"): # pragma: no cover -- env hook
691
+ with contextlib.suppress(AttributeError, ValueError):
692
+ _stream.reconfigure(encoding="utf-8", errors="replace")
693
+
694
+
695
+ __all__ = [
696
+ "Atomic",
697
+ "EVALUATOR_ACTOR",
698
+ "Expression",
699
+ "RESUME_ELIGIBLE_DECISION",
700
+ "ResumeContext",
701
+ "ResumeGrammarError",
702
+ "build_context",
703
+ "evaluate",
704
+ "evaluate_resume_eligibility",
705
+ "parse",
706
+ ]