@deftai/directive-content 0.59.0 → 0.60.0

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