@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,706 +0,0 @@
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
- ]