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.
@@ -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()