specfuse-loop 0.2.0__py3-none-any.whl
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.
- specfuse/loop/__init__.py +5 -0
- specfuse/loop/_miniyaml.py +466 -0
- specfuse/loop/adopt_feature.py +217 -0
- specfuse/loop/gate_eval.py +503 -0
- specfuse/loop/gh_backend.py +82 -0
- specfuse/loop/gh_features.py +98 -0
- specfuse/loop/lint_plan.py +616 -0
- specfuse/loop/loop.py +3504 -0
- specfuse/loop/validate_event.py +282 -0
- specfuse_loop-0.2.0.dist-info/METADATA +192 -0
- specfuse_loop-0.2.0.dist-info/RECORD +16 -0
- specfuse_loop-0.2.0.dist-info/WHEEL +5 -0
- specfuse_loop-0.2.0.dist-info/entry_points.txt +3 -0
- specfuse_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
- specfuse_loop-0.2.0.dist-info/licenses/NOTICE +6 -0
- specfuse_loop-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Deterministic gate-close predicate — pure, side-effect-free module.
|
|
6
|
+
|
|
7
|
+
Given a feature directory and a gate id, evaluates the v1 predicate and
|
|
8
|
+
returns an AutoCloseDecision. No imports from loop.py. No subprocess calls.
|
|
9
|
+
No file writes. Pure read + compute.
|
|
10
|
+
|
|
11
|
+
Coverage pragmas removed in T02 (tests/test_gate_eval.py) — T01 had added
|
|
12
|
+
them temporarily so the overall coverage threshold was unaffected before
|
|
13
|
+
tests landed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import _miniyaml
|
|
26
|
+
|
|
27
|
+
PREDICATE_VERSION = "v1"
|
|
28
|
+
PER_WU_COST_RATIO_CEILING = 1.5
|
|
29
|
+
PER_WU_HARD_OVERRUN_RATIO = 2.0
|
|
30
|
+
PLAN_NEXT_COST_RATIO_CEILING = 1.5
|
|
31
|
+
|
|
32
|
+
_FM_DELIM = re.compile(r"^---\s*$")
|
|
33
|
+
_YAML_BLOCK_RE = re.compile(r"```ya?ml\s*\n(.*?)\n```", re.DOTALL)
|
|
34
|
+
_CLOSING_TYPES = frozenset({"close", "close-intermediate"})
|
|
35
|
+
_NON_SUBSTANTIVE_TYPES = frozenset({"close", "close-intermediate", "plan-next"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class AutoCloseDecision:
|
|
40
|
+
auto: bool # True iff predicate fires
|
|
41
|
+
reasons: list[str] # one entry per failing criterion (empty if auto=True)
|
|
42
|
+
metrics: dict # raw numbers for human inspection
|
|
43
|
+
gate_id: int
|
|
44
|
+
feature_id: str
|
|
45
|
+
predicate_version: str # "v1" — bumped when constants change
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
49
|
+
"""Return (frontmatter_dict, body_text) from ---\n...\n--- delimited text."""
|
|
50
|
+
lines = text.splitlines()
|
|
51
|
+
if not lines or not _FM_DELIM.match(lines[0]):
|
|
52
|
+
return {}, text
|
|
53
|
+
end = next((i for i, ln in enumerate(lines[1:], 1) if _FM_DELIM.match(ln)), None)
|
|
54
|
+
if end is None:
|
|
55
|
+
return {}, text
|
|
56
|
+
fm = _miniyaml.parse("\n".join(lines[1:end])) or {}
|
|
57
|
+
body = "\n".join(lines[end + 1:])
|
|
58
|
+
return fm, body
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_plan_metrics(feature_dir: Path) -> dict:
|
|
62
|
+
"""Return frontmatter + parsed task-graph gates from PLAN.md."""
|
|
63
|
+
text = (feature_dir / "PLAN.md").read_text()
|
|
64
|
+
fm, body = _parse_frontmatter(text)
|
|
65
|
+
gates: list[dict] = []
|
|
66
|
+
m = _YAML_BLOCK_RE.search(body)
|
|
67
|
+
if m:
|
|
68
|
+
graph = _miniyaml.parse(m.group(1)) or {}
|
|
69
|
+
gates = graph.get("gates", []) or []
|
|
70
|
+
return {"frontmatter": fm, "gates": gates}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_wu_metrics(wu_path: Path) -> dict:
|
|
74
|
+
"""Return WU frontmatter as dict, with defaults for missing fields."""
|
|
75
|
+
fm, _ = _parse_frontmatter(wu_path.read_text())
|
|
76
|
+
return {
|
|
77
|
+
"id": fm.get("id", ""),
|
|
78
|
+
"type": fm.get("type", "implementation"),
|
|
79
|
+
"status": fm.get("status", "pending"),
|
|
80
|
+
"attempts": fm.get("attempts", 0),
|
|
81
|
+
"cost_usd": fm.get("cost_usd", 0.0),
|
|
82
|
+
"planned_cost_usd": fm.get("planned_cost_usd"), # None when absent
|
|
83
|
+
"auto_close": fm.get("auto_close"),
|
|
84
|
+
"rearm_count": fm.get("rearm_count", 0), # FEAT-2026-0016; 0 when absent
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_events(events_path: Path, wu_ids: list[str]) -> list[dict]:
|
|
89
|
+
"""Return events whose correlation_id is in wu_ids. Returns [] if file missing."""
|
|
90
|
+
if not events_path.exists():
|
|
91
|
+
return []
|
|
92
|
+
id_set = set(wu_ids)
|
|
93
|
+
out: list[dict] = []
|
|
94
|
+
with events_path.open(encoding="utf-8") as f:
|
|
95
|
+
for line in f:
|
|
96
|
+
stripped = line.strip()
|
|
97
|
+
if not stripped:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
ev = json.loads(stripped)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
continue
|
|
103
|
+
if ev.get("correlation_id") in id_set:
|
|
104
|
+
out.append(ev)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _apply_predicate(
|
|
109
|
+
plan_metrics: dict,
|
|
110
|
+
wu_metrics_list: list[dict],
|
|
111
|
+
events: list[dict],
|
|
112
|
+
gate_budget: float | None,
|
|
113
|
+
) -> tuple[list[str], dict]:
|
|
114
|
+
"""Apply all v1 predicate checks. Collects ALL failure reasons; no short-circuit.
|
|
115
|
+
|
|
116
|
+
Returns (reasons, metrics_dict).
|
|
117
|
+
"""
|
|
118
|
+
reasons: list[str] = []
|
|
119
|
+
warnings: list[str] = []
|
|
120
|
+
|
|
121
|
+
# Index events by WU ID for O(1) lookup
|
|
122
|
+
events_by_wu: dict[str, list[dict]] = {}
|
|
123
|
+
for ev in events:
|
|
124
|
+
cid = ev.get("correlation_id", "")
|
|
125
|
+
events_by_wu.setdefault(cid, []).append(ev)
|
|
126
|
+
|
|
127
|
+
# Metrics accumulators
|
|
128
|
+
per_wu_cost: dict[str, float] = {}
|
|
129
|
+
per_wu_planned: dict[str, float | None] = {}
|
|
130
|
+
gate_total_cost = 0.0
|
|
131
|
+
plan_next_cost: float | None = None
|
|
132
|
+
plan_next_planned: float | None = None
|
|
133
|
+
blocked_human_events: list[str] = []
|
|
134
|
+
replan_events: list[str] = []
|
|
135
|
+
final_outcomes: dict[str, str] = {}
|
|
136
|
+
|
|
137
|
+
# First pass: compute per-WU metrics and determine final outcomes
|
|
138
|
+
for wm in wu_metrics_list:
|
|
139
|
+
wu_id = wm["id"]
|
|
140
|
+
cost = wm["cost_usd"]
|
|
141
|
+
planned = wm["planned_cost_usd"]
|
|
142
|
+
wu_type = wm["type"]
|
|
143
|
+
|
|
144
|
+
per_wu_cost[wu_id] = cost
|
|
145
|
+
per_wu_planned[wu_id] = planned
|
|
146
|
+
gate_total_cost += cost
|
|
147
|
+
|
|
148
|
+
if wu_type == "plan-next":
|
|
149
|
+
plan_next_cost = cost
|
|
150
|
+
plan_next_planned = planned
|
|
151
|
+
|
|
152
|
+
# Determine final outcome from event sequence
|
|
153
|
+
wu_evs = events_by_wu.get(wu_id, [])
|
|
154
|
+
last_terminal = None
|
|
155
|
+
for ev in wu_evs:
|
|
156
|
+
if ev.get("event_type") in ("task_completed", "human_escalation", "blocked_human"):
|
|
157
|
+
last_terminal = ev
|
|
158
|
+
if last_terminal is None:
|
|
159
|
+
final_outcomes[wu_id] = "no_events"
|
|
160
|
+
elif last_terminal.get("event_type") == "task_completed":
|
|
161
|
+
final_outcomes[wu_id] = "passed"
|
|
162
|
+
else:
|
|
163
|
+
final_outcomes[wu_id] = "escalated"
|
|
164
|
+
|
|
165
|
+
# --- Check 1: No blocked_human / human_escalation events ---
|
|
166
|
+
seen_blocked: set[str] = set()
|
|
167
|
+
for ev in events:
|
|
168
|
+
if ev.get("event_type") in ("human_escalation", "blocked_human"):
|
|
169
|
+
wu_id = ev.get("correlation_id", "unknown")
|
|
170
|
+
if wu_id in seen_blocked:
|
|
171
|
+
continue
|
|
172
|
+
seen_blocked.add(wu_id)
|
|
173
|
+
blocked_human_events.append(wu_id)
|
|
174
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
175
|
+
ts = ev.get("timestamp", "")[:10]
|
|
176
|
+
reasons.append(f"blocked_human_in_chain: {sub_id} escalated {ts}")
|
|
177
|
+
|
|
178
|
+
# FEAT-2026-0016 re-arm history: rearm_count > 0 signals a prior blocked cycle
|
|
179
|
+
for wm in wu_metrics_list:
|
|
180
|
+
rearm = wm.get("rearm_count", 0)
|
|
181
|
+
if rearm and rearm > 0:
|
|
182
|
+
wu_id = wm["id"]
|
|
183
|
+
if wu_id not in seen_blocked:
|
|
184
|
+
seen_blocked.add(wu_id)
|
|
185
|
+
blocked_human_events.append(wu_id)
|
|
186
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
187
|
+
reasons.append(
|
|
188
|
+
f"blocked_human_in_chain: {sub_id} (rearm_count={rearm})"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# --- Check 2: No replan events ---
|
|
192
|
+
seen_replan: set[str] = set()
|
|
193
|
+
for ev in events:
|
|
194
|
+
if ev.get("event_type") == "replan":
|
|
195
|
+
wu_id = ev.get("correlation_id", "unknown")
|
|
196
|
+
if wu_id in seen_replan:
|
|
197
|
+
continue
|
|
198
|
+
seen_replan.add(wu_id)
|
|
199
|
+
replan_events.append(wu_id)
|
|
200
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
201
|
+
reasons.append(f"replan_event: {sub_id}")
|
|
202
|
+
|
|
203
|
+
# --- Checks 3 & 4: Per-WU cost ratios (skip close/close-intermediate and plan-next) ---
|
|
204
|
+
for wm in wu_metrics_list:
|
|
205
|
+
wu_type = wm["type"]
|
|
206
|
+
if wu_type in _CLOSING_TYPES or wu_type == "plan-next":
|
|
207
|
+
continue
|
|
208
|
+
wu_id = wm["id"]
|
|
209
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
210
|
+
cost = wm["cost_usd"]
|
|
211
|
+
planned = wm["planned_cost_usd"]
|
|
212
|
+
|
|
213
|
+
if planned is None:
|
|
214
|
+
warnings.append(f"planned_cost_missing: {sub_id}")
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
ratio = (cost / planned) if planned > 0 else (float("inf") if cost > 0 else 0.0)
|
|
218
|
+
|
|
219
|
+
if ratio > PER_WU_COST_RATIO_CEILING:
|
|
220
|
+
reasons.append(
|
|
221
|
+
f"per_wu_cost_overrun: {sub_id} actual=${cost:.2f} "
|
|
222
|
+
f"planned=${planned:.2f} ratio={ratio:.2f}x"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if ratio > PER_WU_HARD_OVERRUN_RATIO:
|
|
226
|
+
reasons.append(
|
|
227
|
+
f"per_wu_hard_overrun: {sub_id} actual=${cost:.2f} "
|
|
228
|
+
f"planned=${planned:.2f} ratio={ratio:.2f}x"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# --- Check 5: Plan-next ≤ 1.5× planned ---
|
|
232
|
+
for wm in wu_metrics_list:
|
|
233
|
+
if wm["type"] != "plan-next":
|
|
234
|
+
continue
|
|
235
|
+
wu_id = wm["id"]
|
|
236
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
237
|
+
cost = wm["cost_usd"]
|
|
238
|
+
planned = wm["planned_cost_usd"]
|
|
239
|
+
|
|
240
|
+
if planned is None:
|
|
241
|
+
warnings.append(f"planned_cost_missing: {sub_id}")
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
ratio = (cost / planned) if planned > 0 else (float("inf") if cost > 0 else 0.0)
|
|
245
|
+
|
|
246
|
+
if ratio > PLAN_NEXT_COST_RATIO_CEILING:
|
|
247
|
+
reasons.append(
|
|
248
|
+
f"plan_next_overrun: {sub_id} actual=${cost:.2f} "
|
|
249
|
+
f"planned=${planned:.2f} ratio={ratio:.2f}x"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# --- Check 6: Gate total ≤ cost_budget_usd (skip when budget absent) ---
|
|
253
|
+
if gate_budget is not None:
|
|
254
|
+
if gate_total_cost > gate_budget:
|
|
255
|
+
reasons.append(
|
|
256
|
+
f"gate_budget_exceeded: total=${gate_total_cost:.2f} "
|
|
257
|
+
f"budget=${gate_budget:.2f}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# --- Check 7: Every substantive WU's final outcome must be passed ---
|
|
261
|
+
for wm in wu_metrics_list:
|
|
262
|
+
if wm["type"] in _NON_SUBSTANTIVE_TYPES:
|
|
263
|
+
continue
|
|
264
|
+
wu_id = wm["id"]
|
|
265
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
266
|
+
outcome = final_outcomes.get(wu_id, "no_events")
|
|
267
|
+
if outcome != "passed":
|
|
268
|
+
reasons.append(f"final_attempt_not_passed: {sub_id} (outcome={outcome})")
|
|
269
|
+
|
|
270
|
+
metrics: dict = {
|
|
271
|
+
"per_wu_cost": per_wu_cost,
|
|
272
|
+
"per_wu_planned": per_wu_planned,
|
|
273
|
+
"gate_total_cost": gate_total_cost,
|
|
274
|
+
"gate_budget": gate_budget,
|
|
275
|
+
"plan_next_cost": plan_next_cost,
|
|
276
|
+
"plan_next_planned": plan_next_planned,
|
|
277
|
+
"blocked_human_events": blocked_human_events,
|
|
278
|
+
"replan_events": replan_events,
|
|
279
|
+
"final_outcomes": final_outcomes,
|
|
280
|
+
"warnings": warnings,
|
|
281
|
+
}
|
|
282
|
+
return reasons, metrics
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def evaluate_auto_close(feature_dir: Path, gate_id: int) -> AutoCloseDecision:
|
|
286
|
+
"""Return AutoCloseDecision for gate_id in the given feature directory."""
|
|
287
|
+
plan = _read_plan_metrics(feature_dir)
|
|
288
|
+
fm = plan["frontmatter"]
|
|
289
|
+
feature_id = fm.get("feature_id", feature_dir.name)
|
|
290
|
+
|
|
291
|
+
# Operator manual override — honored before reading any WU evidence
|
|
292
|
+
if fm.get("auto_close_disabled") is True:
|
|
293
|
+
return AutoCloseDecision(
|
|
294
|
+
auto=False,
|
|
295
|
+
reasons=["auto_close_disabled_per_plan"],
|
|
296
|
+
metrics={"warnings": []},
|
|
297
|
+
gate_id=gate_id,
|
|
298
|
+
feature_id=feature_id,
|
|
299
|
+
predicate_version=PREDICATE_VERSION,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Locate target gate in task graph
|
|
303
|
+
gates = plan["gates"]
|
|
304
|
+
target_gate = next((g for g in gates if g.get("gate") == gate_id), None)
|
|
305
|
+
if target_gate is None:
|
|
306
|
+
return AutoCloseDecision(
|
|
307
|
+
auto=False,
|
|
308
|
+
reasons=[f"gate_not_found: gate {gate_id} absent in PLAN.md graph"],
|
|
309
|
+
metrics={"warnings": []},
|
|
310
|
+
gate_id=gate_id,
|
|
311
|
+
feature_id=feature_id,
|
|
312
|
+
predicate_version=PREDICATE_VERSION,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Load GATE-NN.md frontmatter for optional budget (check 6)
|
|
316
|
+
gate_budget: float | None = None
|
|
317
|
+
gate_file_name = target_gate.get("file", f"GATE-{gate_id:02d}.md")
|
|
318
|
+
gate_path = feature_dir / gate_file_name
|
|
319
|
+
if gate_path.exists():
|
|
320
|
+
gate_fm, _ = _parse_frontmatter(gate_path.read_text())
|
|
321
|
+
gate_budget = gate_fm.get("cost_budget_usd")
|
|
322
|
+
|
|
323
|
+
# Resolve WU list from task graph
|
|
324
|
+
wu_refs = target_gate.get("work_units", []) or []
|
|
325
|
+
wu_ids = [ref["id"] for ref in wu_refs if ref.get("id")]
|
|
326
|
+
|
|
327
|
+
# Load events — graceful degrade if file missing
|
|
328
|
+
events_path = feature_dir / "events.jsonl"
|
|
329
|
+
pre_warnings: list[str] = []
|
|
330
|
+
if not events_path.exists():
|
|
331
|
+
pre_warnings.append("events_jsonl_missing")
|
|
332
|
+
events = _read_events(events_path, wu_ids)
|
|
333
|
+
|
|
334
|
+
# Load WU metrics; missing files are hard failures (refuse partial evaluation)
|
|
335
|
+
wu_metrics_list: list[dict] = []
|
|
336
|
+
missing_reasons: list[str] = []
|
|
337
|
+
for ref in wu_refs:
|
|
338
|
+
wu_id = ref.get("id", "unknown")
|
|
339
|
+
wu_file = ref.get("file", "")
|
|
340
|
+
wu_path = feature_dir / wu_file
|
|
341
|
+
sub_id = wu_id.split("/")[-1] if "/" in wu_id else wu_id
|
|
342
|
+
if not wu_path.exists():
|
|
343
|
+
missing_reasons.append(f"wu_file_missing: {sub_id}")
|
|
344
|
+
else:
|
|
345
|
+
wu_metrics_list.append(_read_wu_metrics(wu_path))
|
|
346
|
+
|
|
347
|
+
if missing_reasons:
|
|
348
|
+
return AutoCloseDecision(
|
|
349
|
+
auto=False,
|
|
350
|
+
reasons=missing_reasons,
|
|
351
|
+
metrics={
|
|
352
|
+
"per_wu_cost": {},
|
|
353
|
+
"per_wu_planned": {},
|
|
354
|
+
"gate_total_cost": 0.0,
|
|
355
|
+
"gate_budget": gate_budget,
|
|
356
|
+
"plan_next_cost": None,
|
|
357
|
+
"plan_next_planned": None,
|
|
358
|
+
"blocked_human_events": [],
|
|
359
|
+
"replan_events": [],
|
|
360
|
+
"final_outcomes": {},
|
|
361
|
+
"warnings": pre_warnings,
|
|
362
|
+
},
|
|
363
|
+
gate_id=gate_id,
|
|
364
|
+
feature_id=feature_id,
|
|
365
|
+
predicate_version=PREDICATE_VERSION,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
reasons, metrics = _apply_predicate(plan, wu_metrics_list, events, gate_budget)
|
|
369
|
+
metrics["warnings"] = pre_warnings + metrics.get("warnings", [])
|
|
370
|
+
|
|
371
|
+
return AutoCloseDecision(
|
|
372
|
+
auto=len(reasons) == 0,
|
|
373
|
+
reasons=reasons,
|
|
374
|
+
metrics=metrics,
|
|
375
|
+
gate_id=gate_id,
|
|
376
|
+
feature_id=feature_id,
|
|
377
|
+
predicate_version=PREDICATE_VERSION,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# CLI helpers (T03)
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _resolve_feature_dir(feature_id: str, repo_root: Path) -> "Path | None":
|
|
387
|
+
"""Resolve a feature ID (full, partial numeric, or slug) to a feature directory.
|
|
388
|
+
|
|
389
|
+
Returns None on no match; raises ValueError on ambiguous partial match.
|
|
390
|
+
"""
|
|
391
|
+
features_dir = repo_root / ".specfuse" / "features"
|
|
392
|
+
if not features_dir.is_dir():
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
candidates = sorted(d for d in features_dir.iterdir() if d.is_dir())
|
|
396
|
+
|
|
397
|
+
# Priority 1: exact directory name
|
|
398
|
+
exact = [d for d in candidates if d.name == feature_id]
|
|
399
|
+
if len(exact) == 1:
|
|
400
|
+
return exact[0]
|
|
401
|
+
|
|
402
|
+
# Priority 2: name starts with "<feature_id>-" (full FEAT-YYYY-NNNN prefix)
|
|
403
|
+
prefix = [d for d in candidates if d.name.startswith(feature_id + "-")]
|
|
404
|
+
if len(prefix) == 1:
|
|
405
|
+
return prefix[0]
|
|
406
|
+
if len(prefix) > 1:
|
|
407
|
+
raise ValueError(f"ambiguous: {[d.name for d in prefix]}")
|
|
408
|
+
|
|
409
|
+
# Priority 3: partial numeric (0017) or slug suffix match
|
|
410
|
+
partial: list[Path] = []
|
|
411
|
+
for d in candidates:
|
|
412
|
+
name = d.name
|
|
413
|
+
parts = name.split("-")
|
|
414
|
+
# FEAT-YYYY-NNNN-slug → parts[2] is the NNNN part
|
|
415
|
+
if len(parts) >= 4:
|
|
416
|
+
nnnn = parts[2]
|
|
417
|
+
slug = "-".join(parts[3:])
|
|
418
|
+
if nnnn == feature_id or slug == feature_id:
|
|
419
|
+
partial.append(d)
|
|
420
|
+
elif feature_id in name:
|
|
421
|
+
partial.append(d)
|
|
422
|
+
|
|
423
|
+
if len(partial) == 0:
|
|
424
|
+
return None
|
|
425
|
+
if len(partial) == 1:
|
|
426
|
+
return partial[0]
|
|
427
|
+
raise ValueError(f"ambiguous: {[d.name for d in partial]}")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _format_decision(decision: AutoCloseDecision) -> str:
|
|
431
|
+
"""Render an AutoCloseDecision to the canonical block shape."""
|
|
432
|
+
lines: list[str] = []
|
|
433
|
+
gate_str = f"G{decision.gate_id:02d}"
|
|
434
|
+
lines.append(f" {gate_str} auto={decision.auto}")
|
|
435
|
+
if decision.reasons:
|
|
436
|
+
lines.append(" reasons:")
|
|
437
|
+
for r in decision.reasons:
|
|
438
|
+
lines.append(f" - {r}")
|
|
439
|
+
lines.append(" metrics:")
|
|
440
|
+
total = decision.metrics.get("gate_total_cost", 0.0)
|
|
441
|
+
budget = decision.metrics.get("gate_budget")
|
|
442
|
+
lines.append(f" gate_total_cost: ${total:.2f}")
|
|
443
|
+
budget_str = f"${budget:.2f}" if budget is not None else "<unset>"
|
|
444
|
+
lines.append(f" gate_budget: {budget_str}")
|
|
445
|
+
return "\n".join(lines)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def main() -> None:
|
|
449
|
+
parser = argparse.ArgumentParser(
|
|
450
|
+
prog="gate_eval.py",
|
|
451
|
+
description=f"Specfuse gate-close predicate CLI (predicate={PREDICATE_VERSION})",
|
|
452
|
+
)
|
|
453
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
454
|
+
|
|
455
|
+
bt = subparsers.add_parser(
|
|
456
|
+
"backtest",
|
|
457
|
+
help="Evaluate the auto-close predicate against a feature directory",
|
|
458
|
+
)
|
|
459
|
+
bt.add_argument("feature_id", help="Feature ID (full FEAT-YYYY-NNNN, partial 0017, or slug)")
|
|
460
|
+
bt.add_argument(
|
|
461
|
+
"--gate",
|
|
462
|
+
type=int,
|
|
463
|
+
metavar="N",
|
|
464
|
+
default=None,
|
|
465
|
+
help="Restrict evaluation to gate N",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
args = parser.parse_args()
|
|
469
|
+
|
|
470
|
+
if args.command is None:
|
|
471
|
+
parser.print_help()
|
|
472
|
+
sys.exit(0)
|
|
473
|
+
|
|
474
|
+
# backtest subcommand
|
|
475
|
+
repo_root = Path(__file__).resolve().parent.parent.parent
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
feature_dir = _resolve_feature_dir(args.feature_id, repo_root)
|
|
479
|
+
except ValueError as exc:
|
|
480
|
+
print(f"ambiguous feature ID: {exc}")
|
|
481
|
+
sys.exit(0)
|
|
482
|
+
|
|
483
|
+
if feature_dir is None:
|
|
484
|
+
print(f"no feature matches: {args.feature_id}")
|
|
485
|
+
sys.exit(0)
|
|
486
|
+
|
|
487
|
+
plan = _read_plan_metrics(feature_dir)
|
|
488
|
+
fm = plan["frontmatter"]
|
|
489
|
+
feature_id = fm.get("feature_id", feature_dir.name)
|
|
490
|
+
gates = plan["gates"]
|
|
491
|
+
|
|
492
|
+
gate_ids = [g["gate"] for g in gates if isinstance(g.get("gate"), int)]
|
|
493
|
+
if args.gate is not None:
|
|
494
|
+
gate_ids = [gid for gid in gate_ids if gid == args.gate]
|
|
495
|
+
|
|
496
|
+
print(f"{feature_id} predicate={PREDICATE_VERSION}")
|
|
497
|
+
for gate_id in gate_ids:
|
|
498
|
+
decision = evaluate_auto_close(feature_dir, gate_id)
|
|
499
|
+
print(_format_decision(decision))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Specfuse contributors
|
|
4
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
5
|
+
#
|
|
6
|
+
"""GitHubBackend: Backend subclass that emits state:* label transitions on lifecycle events."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
|
|
13
|
+
from . import loop as _loop
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_runner(args: list) -> None:
|
|
17
|
+
"""Shell out to gh with the given argument list. Not called in tests."""
|
|
18
|
+
subprocess.run(args, check=True, capture_output=True, text=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubBackend(_loop.Backend):
|
|
22
|
+
"""Backend that transitions state:* labels on a GitHub issue per lifecycle event.
|
|
23
|
+
|
|
24
|
+
Lifecycle mapping:
|
|
25
|
+
on_feature_start -> add state:in-progress, remove state:ready
|
|
26
|
+
on_gate_passed -> no-op v0.1 (gate observability lives in event log)
|
|
27
|
+
on_feature_complete -> add state:done, remove state:in-progress
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
repo: str,
|
|
33
|
+
issue_number: int,
|
|
34
|
+
runner: Optional[Callable] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.repo = repo
|
|
37
|
+
self.issue_number = issue_number
|
|
38
|
+
self._runner = runner if runner is not None else _default_runner
|
|
39
|
+
|
|
40
|
+
def on_feature_start(self, feature_id: str, feat_fm: dict) -> None:
|
|
41
|
+
self._feat_fm = feat_fm # stored for on_feature_complete
|
|
42
|
+
self._runner([
|
|
43
|
+
"gh", "issue", "edit", str(self.issue_number),
|
|
44
|
+
"--repo", self.repo,
|
|
45
|
+
"--add-label", "state:in-progress",
|
|
46
|
+
"--remove-label", "state:ready",
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
def on_gate_passed(self, feature_id: str, gate_number: int) -> None:
|
|
50
|
+
"""No-op v0.1 stub: gate-level observability lives in the per-feature event log."""
|
|
51
|
+
|
|
52
|
+
def on_feature_complete(self, feature_id: str) -> None:
|
|
53
|
+
feat_fm = getattr(self, "_feat_fm", {})
|
|
54
|
+
branch = feat_fm.get("branch", "")
|
|
55
|
+
title = feat_fm.get("title", feature_id)
|
|
56
|
+
|
|
57
|
+
# Idempotent: skip PR creation if one already exists for this branch.
|
|
58
|
+
check = subprocess.run(
|
|
59
|
+
["gh", "pr", "view", branch, "--repo", self.repo, "--json", "number"],
|
|
60
|
+
capture_output=True, text=True,
|
|
61
|
+
)
|
|
62
|
+
if check.returncode != 0:
|
|
63
|
+
body = (
|
|
64
|
+
f"Closes #{self.issue_number}\n\n"
|
|
65
|
+
f"Correlation: `{feature_id}`\n\n"
|
|
66
|
+
f"Part of initiative `{feat_fm.get('initiative', '')}`. "
|
|
67
|
+
f"All loop gates passed; see feature event log for details."
|
|
68
|
+
)
|
|
69
|
+
self._runner([
|
|
70
|
+
"gh", "pr", "create",
|
|
71
|
+
"--title", f"[{feature_id}] {title}",
|
|
72
|
+
"--body", body,
|
|
73
|
+
"--base", "main",
|
|
74
|
+
"--head", branch,
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
self._runner([
|
|
78
|
+
"gh", "issue", "edit", str(self.issue_number),
|
|
79
|
+
"--repo", self.repo,
|
|
80
|
+
"--add-label", "state:done",
|
|
81
|
+
"--remove-label", "state:in-progress",
|
|
82
|
+
])
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Specfuse contributors
|
|
4
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
5
|
+
#
|
|
6
|
+
"""Discovery: list a repo's open specfuse:feature GitHub issues as loop-feature candidates."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
# Matches [INIT-YYYY-NNNN/FNN] or [FEAT-YYYY-NNNN] at the start of a title.
|
|
17
|
+
_TITLE_RE = re.compile(
|
|
18
|
+
r"^\[(?P<id>(?:INIT-\d{4}-\d{4}/F\d{2}|FEAT-\d{4}-\d{4}))\]\s*(?P<summary>.*)$"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_runner(repo: str) -> list:
|
|
23
|
+
"""Shell out to gh and return parsed JSON issue list. Not called in tests."""
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
[
|
|
26
|
+
"gh", "issue", "list",
|
|
27
|
+
"--repo", repo,
|
|
28
|
+
"--label", "specfuse:feature",
|
|
29
|
+
"--state", "open",
|
|
30
|
+
"--json", "number,title,labels,url,body",
|
|
31
|
+
],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
check=True,
|
|
35
|
+
)
|
|
36
|
+
return json.loads(result.stdout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_label_value(labels: list, prefix: str) -> Optional[str]:
|
|
40
|
+
for label in labels:
|
|
41
|
+
name = label.get("name", "")
|
|
42
|
+
if name.startswith(prefix):
|
|
43
|
+
return name[len(prefix):]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_features(repo: str, runner: Any = None) -> list:
|
|
48
|
+
"""Return loop-feature candidates for open specfuse:feature issues in repo.
|
|
49
|
+
|
|
50
|
+
runner: callable(repo: str) -> list[dict]. Defaults to shelling out to gh.
|
|
51
|
+
Issues whose titles lack a parseable [<id>] tag are skipped with a warning.
|
|
52
|
+
"""
|
|
53
|
+
if runner is None:
|
|
54
|
+
runner = _default_runner
|
|
55
|
+
|
|
56
|
+
issues = runner(repo)
|
|
57
|
+
candidates = []
|
|
58
|
+
|
|
59
|
+
for issue in issues:
|
|
60
|
+
title = issue.get("title", "")
|
|
61
|
+
m = _TITLE_RE.match(title)
|
|
62
|
+
if not m:
|
|
63
|
+
print(
|
|
64
|
+
f"WARNING: skipping issue #{issue.get('number')}: "
|
|
65
|
+
f"no [<id>] tag in title: {title!r}",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
labels = issue.get("labels", [])
|
|
71
|
+
candidates.append({
|
|
72
|
+
"feature_id": m.group("id"),
|
|
73
|
+
"title": m.group("summary").strip(),
|
|
74
|
+
"initiative": _extract_label_value(labels, "initiative:"),
|
|
75
|
+
"task_type": _extract_label_value(labels, "type:"),
|
|
76
|
+
"autonomy": _extract_label_value(labels, "autonomy:") or "review",
|
|
77
|
+
"url": issue.get("url", ""),
|
|
78
|
+
"number": issue.get("number"),
|
|
79
|
+
"body": issue.get("body", ""),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return candidates
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(_runner: Any = None) -> None:
|
|
86
|
+
"""CLI entrypoint. _runner is injectable for tests."""
|
|
87
|
+
if len(sys.argv) != 2:
|
|
88
|
+
print(f"Usage: {sys.argv[0]} <owner/repo>", file=sys.stderr)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
repo = sys.argv[1]
|
|
92
|
+
candidates = list_features(repo, runner=_runner)
|
|
93
|
+
for c in candidates:
|
|
94
|
+
print(f"{c['feature_id']}\t{c['task_type']}\t{c['autonomy']}\t{c['url']}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|