@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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- 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
|
+
]
|