issue-flow 0.4.3__tar.gz → 0.4.3.post3__tar.gz
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.
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/PKG-INFO +1 -1
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/pyproject.toml +1 -1
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/agent.py +393 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/cli.py +80 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/config.py +3 -0
- issue_flow-0.4.3.post3/src/issue_flow/epicplan.py +163 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/gitutils.py +74 -0
- issue_flow-0.4.3.post3/src/issue_flow/queueplan.py +126 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/step_profiles.toml +2 -0
- issue_flow-0.4.3.post3/src/issue_flow/templates/commands/iflow-cycle.md.j2 +33 -0
- issue_flow-0.4.3.post3/src/issue_flow/templates/commands/iflow-epic.md.j2 +35 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow.md.j2 +1 -1
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/docs/issue-workflow.md.j2 +3 -1
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/_body.md.j2 +4 -0
- issue_flow-0.4.3.post3/src/issue_flow/templates/skills/iflow_cycle/SKILL.md.j2 +65 -0
- issue_flow-0.4.3.post3/src/issue_flow/templates/skills/iflow_epic/SKILL.md.j2 +101 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_iflow/SKILL.md.j2 +1 -1
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templating.py +4 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/LICENSE +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/README.md +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/__init__.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/dependencies.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/editors.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/graphify.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/init.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/modes.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/modes.toml +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/project.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/py.typed +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/step_profiles.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/__init__.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/__init__.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-archive.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-cleanup.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-close.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-fix.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-graphify.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-init.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-pause.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-pick.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-plan.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-start.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-status.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-yolo.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/designs/python-quality-tools.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/docs/__init__.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/docs/this-project.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/AGENTS.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/CLAUDE.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/__init__.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/_invocation_forms.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/_model_directive.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/_resolve_project_root.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/caveman/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/grill_me/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_archive/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_close/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_comments/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_fix/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_graphify/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_history_update/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_init/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_pause/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_pick/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_plan/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_start/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_status/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_version_bump/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_yolo/SKILL.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/tools/README.md.j2 +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/tracking.py +0 -0
- {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/versionplan.py +0 -0
|
@@ -545,6 +545,399 @@ def _render_version_plan_text(
|
|
|
545
545
|
console.print(f" [{style}]{escape(note)}[/{style}]")
|
|
546
546
|
|
|
547
547
|
|
|
548
|
+
# ---------------------------------------------------------------------------
|
|
549
|
+
# agent epic-status
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def run_epic_status(
|
|
554
|
+
project_root: Path,
|
|
555
|
+
console: Console,
|
|
556
|
+
number: int,
|
|
557
|
+
local: bool,
|
|
558
|
+
as_json: bool,
|
|
559
|
+
) -> int:
|
|
560
|
+
"""Deterministic epic progress: stages, per-issue state, next candidates.
|
|
561
|
+
|
|
562
|
+
Read-only. Parses ``epic<N>_plan.md`` (the contract the /iflow-epic skill
|
|
563
|
+
writes) and — unless ``local`` — resolves each published issue's state via
|
|
564
|
+
``gh``. A missing/unauthenticated ``gh`` degrades to ``state: "unknown"``
|
|
565
|
+
per issue rather than failing the command.
|
|
566
|
+
"""
|
|
567
|
+
from issue_flow import epicplan
|
|
568
|
+
|
|
569
|
+
settings = Settings()
|
|
570
|
+
plan_path = (
|
|
571
|
+
project_root
|
|
572
|
+
/ settings.issueflows_dir
|
|
573
|
+
/ settings.epics_folder
|
|
574
|
+
/ f"epic{number}_plan.md"
|
|
575
|
+
)
|
|
576
|
+
plan = epicplan.parse_epic_plan(plan_path)
|
|
577
|
+
if plan is None:
|
|
578
|
+
msg = f"no epic plan found at {plan_path}; draft one with /iflow-epic {number}."
|
|
579
|
+
if as_json:
|
|
580
|
+
_emit_json(console, {"epic": number, "error": msg})
|
|
581
|
+
else:
|
|
582
|
+
console.print(f"[red]error[/red] {escape(msg)}")
|
|
583
|
+
return 1
|
|
584
|
+
|
|
585
|
+
repo_slug: str | None = None
|
|
586
|
+
owner_repo = gitutils.remote_owner_repo(project_root)
|
|
587
|
+
if owner_repo is not None:
|
|
588
|
+
repo_slug = f"{owner_repo[0]}/{owner_repo[1]}"
|
|
589
|
+
|
|
590
|
+
states: dict[int, str | None] = {}
|
|
591
|
+
if not local:
|
|
592
|
+
for stage in plan.stages:
|
|
593
|
+
for spec in stage.issues:
|
|
594
|
+
if spec.published is not None:
|
|
595
|
+
states[spec.published] = gitutils.gh_issue_state(
|
|
596
|
+
spec.published, project_root, repo_slug
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def spec_state(spec: epicplan.IssueSpec) -> str:
|
|
600
|
+
if spec.published is None:
|
|
601
|
+
return "unpublished"
|
|
602
|
+
if local:
|
|
603
|
+
return "published"
|
|
604
|
+
state = states.get(spec.published)
|
|
605
|
+
return state if state in ("open", "closed") else "unknown"
|
|
606
|
+
|
|
607
|
+
def dep_closed(dep: int) -> bool:
|
|
608
|
+
# A dependency counts as satisfied only when provably closed.
|
|
609
|
+
return (not local) and states.get(dep) == "closed"
|
|
610
|
+
|
|
611
|
+
stage_payloads: list[dict[str, Any]] = []
|
|
612
|
+
current_stage: int | None = None
|
|
613
|
+
next_candidates: list[int] = []
|
|
614
|
+
for stage in plan.stages:
|
|
615
|
+
issues: list[dict[str, Any]] = []
|
|
616
|
+
done = bool(stage.issues)
|
|
617
|
+
for spec in stage.issues:
|
|
618
|
+
state = spec_state(spec)
|
|
619
|
+
blocked_by = [dep for dep in spec.depends_on if not dep_closed(dep)]
|
|
620
|
+
issues.append(
|
|
621
|
+
{
|
|
622
|
+
"number": spec.published,
|
|
623
|
+
"title": spec.title,
|
|
624
|
+
"state": state,
|
|
625
|
+
"depends_on": spec.depends_on,
|
|
626
|
+
"placeholder_deps": [
|
|
627
|
+
f"stage {j} issue {k}" for j, k in spec.placeholder_deps
|
|
628
|
+
],
|
|
629
|
+
"blocked_by": blocked_by,
|
|
630
|
+
"yolo": spec.yolo,
|
|
631
|
+
}
|
|
632
|
+
)
|
|
633
|
+
if state != "closed":
|
|
634
|
+
done = False
|
|
635
|
+
stage_payloads.append(
|
|
636
|
+
{
|
|
637
|
+
"index": stage.index,
|
|
638
|
+
"title": stage.title,
|
|
639
|
+
"issues": issues,
|
|
640
|
+
"done": done,
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
if not done and current_stage is None:
|
|
644
|
+
current_stage = stage.index
|
|
645
|
+
for item in issues:
|
|
646
|
+
if (
|
|
647
|
+
item["state"] == "open"
|
|
648
|
+
and not item["blocked_by"]
|
|
649
|
+
and not item["placeholder_deps"]
|
|
650
|
+
):
|
|
651
|
+
next_candidates.append(item["number"])
|
|
652
|
+
|
|
653
|
+
payload: dict[str, Any] = {
|
|
654
|
+
"epic": plan.number if plan.number is not None else number,
|
|
655
|
+
"title": plan.title,
|
|
656
|
+
"plan_status": plan.status,
|
|
657
|
+
"local": local,
|
|
658
|
+
"stages": stage_payloads,
|
|
659
|
+
"current_stage": current_stage,
|
|
660
|
+
"next_candidates": next_candidates,
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if as_json:
|
|
664
|
+
_emit_json(console, payload)
|
|
665
|
+
return 0
|
|
666
|
+
|
|
667
|
+
console.print(
|
|
668
|
+
f"[bold]Epic #{payload['epic']}[/bold] — {escape(plan.title)} "
|
|
669
|
+
f"[dim](plan: {plan.status})[/dim]"
|
|
670
|
+
)
|
|
671
|
+
for stage in stage_payloads:
|
|
672
|
+
marker = (
|
|
673
|
+
"done"
|
|
674
|
+
if stage["done"]
|
|
675
|
+
else ("current" if stage["index"] == current_stage else "pending")
|
|
676
|
+
)
|
|
677
|
+
console.print(f" Stage {stage['index']} — {escape(stage['title'])} [{marker}]")
|
|
678
|
+
for item in stage["issues"]:
|
|
679
|
+
number_str = f"#{item['number']}" if item["number"] else "(unpublished)"
|
|
680
|
+
flags = " yolo" if item["yolo"] else ""
|
|
681
|
+
blocked = (
|
|
682
|
+
f" blocked by {', '.join(f'#{d}' for d in item['blocked_by'])}"
|
|
683
|
+
if item["blocked_by"]
|
|
684
|
+
else ""
|
|
685
|
+
)
|
|
686
|
+
console.print(
|
|
687
|
+
f" {number_str} [{item['state']}]{flags}{blocked} "
|
|
688
|
+
f"{escape(item['title'])}"
|
|
689
|
+
)
|
|
690
|
+
if next_candidates:
|
|
691
|
+
console.print(
|
|
692
|
+
"[bold]Next candidates[/bold]: "
|
|
693
|
+
+ ", ".join(f"#{n}" for n in next_candidates)
|
|
694
|
+
)
|
|
695
|
+
return 0
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# ---------------------------------------------------------------------------
|
|
699
|
+
# agent queue
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _yolo_from_labels(labels: object) -> bool:
|
|
704
|
+
if not isinstance(labels, list):
|
|
705
|
+
return False
|
|
706
|
+
for label in labels:
|
|
707
|
+
name = label.get("name") if isinstance(label, dict) else label
|
|
708
|
+
if isinstance(name, str) and name.lower() == "yolo":
|
|
709
|
+
return True
|
|
710
|
+
return False
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def run_queue(
|
|
714
|
+
project_root: Path,
|
|
715
|
+
console: Console,
|
|
716
|
+
numbers: list[int],
|
|
717
|
+
label: str | None,
|
|
718
|
+
epic: int | None,
|
|
719
|
+
as_json: bool,
|
|
720
|
+
) -> int:
|
|
721
|
+
"""Plan an execution queue for the cycling workflow (read-only).
|
|
722
|
+
|
|
723
|
+
Exactly one source: explicit issue numbers, a label, or an epic's current
|
|
724
|
+
stage. Dependencies come from ``Depends on #N`` / ``Blocked by #N`` lines
|
|
725
|
+
(or the epic plan); the result is a deterministic topological order plus
|
|
726
|
+
blocked / skipped / independent sets. Cycles abort with exit 1.
|
|
727
|
+
"""
|
|
728
|
+
from issue_flow import epicplan, queueplan
|
|
729
|
+
|
|
730
|
+
settings = Settings()
|
|
731
|
+
sources = sum(1 for source in (numbers, label, epic) if source)
|
|
732
|
+
if sources != 1:
|
|
733
|
+
msg = "give exactly one source: issue numbers, --label, or --epic."
|
|
734
|
+
if as_json:
|
|
735
|
+
_emit_json(console, {"error": msg})
|
|
736
|
+
else:
|
|
737
|
+
console.print(f"[red]error[/red] {msg}")
|
|
738
|
+
return 2
|
|
739
|
+
|
|
740
|
+
repo_slug: str | None = None
|
|
741
|
+
owner_repo = gitutils.remote_owner_repo(project_root)
|
|
742
|
+
if owner_repo is not None:
|
|
743
|
+
repo_slug = f"{owner_repo[0]}/{owner_repo[1]}"
|
|
744
|
+
|
|
745
|
+
notes: list[str] = []
|
|
746
|
+
items: list[queueplan.QueueItem] = []
|
|
747
|
+
source: dict[str, Any]
|
|
748
|
+
|
|
749
|
+
if numbers:
|
|
750
|
+
source = {"type": "numbers", "value": numbers}
|
|
751
|
+
missing: list[int] = []
|
|
752
|
+
for number in numbers:
|
|
753
|
+
meta = gitutils.gh_issue_meta(number, project_root, repo_slug)
|
|
754
|
+
if meta is None:
|
|
755
|
+
missing.append(number)
|
|
756
|
+
continue
|
|
757
|
+
items.append(
|
|
758
|
+
queueplan.QueueItem(
|
|
759
|
+
number=meta.get("number", number),
|
|
760
|
+
title=meta.get("title", ""),
|
|
761
|
+
state=str(meta.get("state", "unknown")).lower(),
|
|
762
|
+
yolo=_yolo_from_labels(meta.get("labels")),
|
|
763
|
+
depends_on=queueplan.parse_dependencies(meta.get("body") or ""),
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
if missing:
|
|
767
|
+
# A typo must never shrink the confirmed queue silently.
|
|
768
|
+
msg = (
|
|
769
|
+
"could not fetch issue(s) "
|
|
770
|
+
+ ", ".join(f"#{n}" for n in missing)
|
|
771
|
+
+ " (gh missing/unauthenticated, or wrong number); refusing to "
|
|
772
|
+
"plan a partial queue."
|
|
773
|
+
)
|
|
774
|
+
if as_json:
|
|
775
|
+
_emit_json(console, {"source": source, "error": msg})
|
|
776
|
+
else:
|
|
777
|
+
console.print(f"[red]error[/red] {msg}")
|
|
778
|
+
return 1
|
|
779
|
+
elif label:
|
|
780
|
+
source = {"type": "label", "value": label}
|
|
781
|
+
listing = gitutils.gh_issue_list_meta(project_root, repo_slug, label=label)
|
|
782
|
+
if listing is None:
|
|
783
|
+
msg = "gh is unavailable or unauthenticated; cannot list issues."
|
|
784
|
+
if as_json:
|
|
785
|
+
_emit_json(console, {"source": source, "error": msg})
|
|
786
|
+
else:
|
|
787
|
+
console.print(f"[red]error[/red] {msg}")
|
|
788
|
+
return 1
|
|
789
|
+
for meta in listing:
|
|
790
|
+
items.append(
|
|
791
|
+
queueplan.QueueItem(
|
|
792
|
+
number=meta.get("number", 0),
|
|
793
|
+
title=meta.get("title", ""),
|
|
794
|
+
state=str(meta.get("state", "open")).lower(),
|
|
795
|
+
yolo=_yolo_from_labels(meta.get("labels")),
|
|
796
|
+
depends_on=queueplan.parse_dependencies(meta.get("body") or ""),
|
|
797
|
+
)
|
|
798
|
+
)
|
|
799
|
+
else:
|
|
800
|
+
source = {"type": "epic", "value": epic}
|
|
801
|
+
plan_path = (
|
|
802
|
+
project_root
|
|
803
|
+
/ settings.issueflows_dir
|
|
804
|
+
/ settings.epics_folder
|
|
805
|
+
/ f"epic{epic}_plan.md"
|
|
806
|
+
)
|
|
807
|
+
epic_plan = epicplan.parse_epic_plan(plan_path)
|
|
808
|
+
if epic_plan is None:
|
|
809
|
+
msg = f"no epic plan found at {plan_path}."
|
|
810
|
+
if as_json:
|
|
811
|
+
_emit_json(console, {"source": source, "error": msg})
|
|
812
|
+
else:
|
|
813
|
+
console.print(f"[red]error[/red] {msg}")
|
|
814
|
+
return 1
|
|
815
|
+
# Current stage: the first stage whose published specs are not all
|
|
816
|
+
# closed (or that still has unpublished specs).
|
|
817
|
+
states: dict[int, str] = {}
|
|
818
|
+
chosen = None
|
|
819
|
+
for stage in epic_plan.stages:
|
|
820
|
+
stage_done = bool(stage.issues)
|
|
821
|
+
for spec in stage.issues:
|
|
822
|
+
if spec.published is None:
|
|
823
|
+
stage_done = False
|
|
824
|
+
continue
|
|
825
|
+
state = gitutils.gh_issue_state(spec.published, project_root, repo_slug)
|
|
826
|
+
states[spec.published] = state or "unknown"
|
|
827
|
+
if state != "closed":
|
|
828
|
+
stage_done = False
|
|
829
|
+
if not stage_done:
|
|
830
|
+
chosen = stage
|
|
831
|
+
break
|
|
832
|
+
if chosen is None:
|
|
833
|
+
notes.append("every stage of the epic is complete; nothing to queue.")
|
|
834
|
+
else:
|
|
835
|
+
source["stage"] = chosen.index
|
|
836
|
+
unpublished = [
|
|
837
|
+
spec.title for spec in chosen.issues if spec.published is None
|
|
838
|
+
]
|
|
839
|
+
if unpublished:
|
|
840
|
+
notes.append(
|
|
841
|
+
"unpublished specs are not queueable: "
|
|
842
|
+
+ "; ".join(unpublished)
|
|
843
|
+
+ " — run the epic publish action first."
|
|
844
|
+
)
|
|
845
|
+
for spec in chosen.issues:
|
|
846
|
+
if spec.published is None:
|
|
847
|
+
continue
|
|
848
|
+
items.append(
|
|
849
|
+
queueplan.QueueItem(
|
|
850
|
+
number=spec.published,
|
|
851
|
+
title=spec.title,
|
|
852
|
+
state=states.get(spec.published, "unknown"),
|
|
853
|
+
yolo=spec.yolo,
|
|
854
|
+
depends_on=list(spec.depends_on),
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
plan = queueplan.build_queue(items)
|
|
859
|
+
|
|
860
|
+
if plan.cycle:
|
|
861
|
+
payload = {
|
|
862
|
+
"source": source,
|
|
863
|
+
"error": "dependency cycle detected",
|
|
864
|
+
"cycle": plan.cycle,
|
|
865
|
+
}
|
|
866
|
+
if as_json:
|
|
867
|
+
_emit_json(console, payload)
|
|
868
|
+
else:
|
|
869
|
+
console.print(
|
|
870
|
+
"[red]error[/red] dependency cycle detected among "
|
|
871
|
+
+ ", ".join(f"#{n}" for n in plan.cycle)
|
|
872
|
+
+ " — fix the Depends on lines; nothing was planned."
|
|
873
|
+
)
|
|
874
|
+
return 1
|
|
875
|
+
|
|
876
|
+
payload = {
|
|
877
|
+
"source": source,
|
|
878
|
+
"queue": [
|
|
879
|
+
{
|
|
880
|
+
"order": position + 1,
|
|
881
|
+
"number": item.number,
|
|
882
|
+
"title": item.title,
|
|
883
|
+
"yolo": item.yolo,
|
|
884
|
+
"depends_on": item.depends_on,
|
|
885
|
+
}
|
|
886
|
+
for position, item in enumerate(plan.ordered)
|
|
887
|
+
],
|
|
888
|
+
"blocked": [
|
|
889
|
+
{
|
|
890
|
+
"number": item.number,
|
|
891
|
+
"title": item.title,
|
|
892
|
+
"open_external_deps": deps,
|
|
893
|
+
}
|
|
894
|
+
for item, deps in plan.blocked
|
|
895
|
+
],
|
|
896
|
+
"skipped_closed": [item.number for item in plan.skipped_closed],
|
|
897
|
+
"independent": plan.independent,
|
|
898
|
+
"notes": notes,
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if as_json:
|
|
902
|
+
_emit_json(console, payload)
|
|
903
|
+
return 0
|
|
904
|
+
|
|
905
|
+
if not plan.ordered:
|
|
906
|
+
console.print("[dim]Nothing to queue.[/dim]")
|
|
907
|
+
for entry in payload["queue"]:
|
|
908
|
+
flags = " [yolo]" if entry["yolo"] else ""
|
|
909
|
+
deps = (
|
|
910
|
+
f" (after {', '.join(f'#{d}' for d in entry['depends_on'])})"
|
|
911
|
+
if entry["depends_on"]
|
|
912
|
+
else ""
|
|
913
|
+
)
|
|
914
|
+
console.print(
|
|
915
|
+
f" {entry['order']}. #{entry['number']}{flags} "
|
|
916
|
+
f"{escape(entry['title'])}{deps}"
|
|
917
|
+
)
|
|
918
|
+
for entry in payload["blocked"]:
|
|
919
|
+
console.print(
|
|
920
|
+
f" [yellow]blocked[/yellow] #{entry['number']} "
|
|
921
|
+
f"{escape(entry['title'])} — waiting on "
|
|
922
|
+
+ ", ".join(f"#{d}" for d in entry["open_external_deps"])
|
|
923
|
+
)
|
|
924
|
+
if plan.skipped_closed:
|
|
925
|
+
console.print(
|
|
926
|
+
" [dim]skipped (closed): "
|
|
927
|
+
+ ", ".join(f"#{n}" for n in payload["skipped_closed"])
|
|
928
|
+
+ "[/dim]"
|
|
929
|
+
)
|
|
930
|
+
if plan.independent:
|
|
931
|
+
console.print(
|
|
932
|
+
" [dim]independent (parallel-safe): "
|
|
933
|
+
+ ", ".join(f"#{n}" for n in plan.independent)
|
|
934
|
+
+ "[/dim]"
|
|
935
|
+
)
|
|
936
|
+
for note in notes:
|
|
937
|
+
console.print(f" [dim]{escape(note)}[/dim]")
|
|
938
|
+
return 0
|
|
939
|
+
|
|
940
|
+
|
|
548
941
|
# ---------------------------------------------------------------------------
|
|
549
942
|
# workspace init
|
|
550
943
|
# ---------------------------------------------------------------------------
|
|
@@ -319,6 +319,86 @@ def agent_version_plan(
|
|
|
319
319
|
)
|
|
320
320
|
|
|
321
321
|
|
|
322
|
+
@agent_app.command("epic-status")
|
|
323
|
+
def agent_epic_status(
|
|
324
|
+
number: int = typer.Argument(..., help="Epic anchor issue number."),
|
|
325
|
+
project_dir: Path = typer.Option(
|
|
326
|
+
Path("."),
|
|
327
|
+
"--project-dir",
|
|
328
|
+
"-C",
|
|
329
|
+
help="Project root directory (defaults to current directory).",
|
|
330
|
+
exists=True,
|
|
331
|
+
file_okay=False,
|
|
332
|
+
resolve_path=True,
|
|
333
|
+
),
|
|
334
|
+
local: bool = typer.Option(
|
|
335
|
+
False,
|
|
336
|
+
"--local",
|
|
337
|
+
help="Skip the GitHub state lookups; report only the local plan file.",
|
|
338
|
+
),
|
|
339
|
+
json_output: bool = typer.Option(
|
|
340
|
+
False, "--json", help="Emit a machine-readable JSON object."
|
|
341
|
+
),
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Deterministic epic progress (read-only).
|
|
344
|
+
|
|
345
|
+
Parses ``.issueflows/05-epics/epic<N>_plan.md`` (the structure written by
|
|
346
|
+
the /iflow-epic skill) and cross-references published issue states via
|
|
347
|
+
``gh``: stages with per-issue state and blockers, the current stage, and
|
|
348
|
+
the next open, unblocked candidates. Exit 1 when no plan file exists.
|
|
349
|
+
"""
|
|
350
|
+
from issue_flow.agent import run_epic_status
|
|
351
|
+
|
|
352
|
+
raise typer.Exit(
|
|
353
|
+
code=run_epic_status(project_dir, _console, number, local, json_output)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@agent_app.command("queue")
|
|
358
|
+
def agent_queue(
|
|
359
|
+
numbers: list[int] = typer.Argument(
|
|
360
|
+
None,
|
|
361
|
+
help="Explicit issue numbers to queue (alternative to --label/--epic).",
|
|
362
|
+
),
|
|
363
|
+
project_dir: Path = typer.Option(
|
|
364
|
+
Path("."),
|
|
365
|
+
"--project-dir",
|
|
366
|
+
"-C",
|
|
367
|
+
help="Project root directory (defaults to current directory).",
|
|
368
|
+
exists=True,
|
|
369
|
+
file_okay=False,
|
|
370
|
+
resolve_path=True,
|
|
371
|
+
),
|
|
372
|
+
label: str | None = typer.Option(
|
|
373
|
+
None, "--label", help="Queue every open issue carrying this label."
|
|
374
|
+
),
|
|
375
|
+
epic: int | None = typer.Option(
|
|
376
|
+
None,
|
|
377
|
+
"--epic",
|
|
378
|
+
help="Queue the current stage of this epic's plan file.",
|
|
379
|
+
),
|
|
380
|
+
json_output: bool = typer.Option(
|
|
381
|
+
False, "--json", help="Emit a machine-readable JSON object."
|
|
382
|
+
),
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Plan a hands-off execution queue (read-only).
|
|
385
|
+
|
|
386
|
+
Exactly one source — explicit numbers, ``--label``, or ``--epic`` — is
|
|
387
|
+
resolved, ``Depends on #N`` / ``Blocked by #N`` lines are parsed, and the
|
|
388
|
+
result is a deterministic topological order plus ``blocked`` (open
|
|
389
|
+
dependencies outside the queue), ``skipped_closed``, and ``independent``
|
|
390
|
+
(parallel-safe) sets. A dependency cycle aborts with exit 1, naming the
|
|
391
|
+
members; nothing is ever executed by this command.
|
|
392
|
+
"""
|
|
393
|
+
from issue_flow.agent import run_queue
|
|
394
|
+
|
|
395
|
+
raise typer.Exit(
|
|
396
|
+
code=run_queue(
|
|
397
|
+
project_dir, _console, list(numbers or []), label, epic, json_output
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
322
402
|
@agent_app.command("resolve")
|
|
323
403
|
def agent_resolve(
|
|
324
404
|
project_dir: Path = typer.Option(
|
|
@@ -67,6 +67,7 @@ class Settings:
|
|
|
67
67
|
partly_solved_folder: str = "02-partly-solved-issues"
|
|
68
68
|
solved_folder: str = "03-solved-issues"
|
|
69
69
|
designs_folder: str = "04-designs-and-guides"
|
|
70
|
+
epics_folder: str = "05-epics"
|
|
70
71
|
|
|
71
72
|
@property
|
|
72
73
|
def issueflows_subdirs(self) -> list[str]:
|
|
@@ -76,6 +77,7 @@ class Settings:
|
|
|
76
77
|
self.partly_solved_folder,
|
|
77
78
|
self.solved_folder,
|
|
78
79
|
self.designs_folder,
|
|
80
|
+
self.epics_folder,
|
|
79
81
|
]
|
|
80
82
|
|
|
81
83
|
def agent_dir_for(self, profile: EditorProfile) -> str:
|
|
@@ -315,6 +317,7 @@ class Settings:
|
|
|
315
317
|
"partly_solved_folder": self.partly_solved_folder,
|
|
316
318
|
"solved_folder": self.solved_folder,
|
|
317
319
|
"designs_folder": self.designs_folder,
|
|
320
|
+
"epics_folder": self.epics_folder,
|
|
318
321
|
"project_name": project_name,
|
|
319
322
|
"editor": profile.id,
|
|
320
323
|
"editor_name": profile.name,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Parser for staged epic plan files (``epic<N>_plan.md``).
|
|
2
|
+
|
|
3
|
+
The ``/iflow-epic`` skill drafts a plan whose structure is a deliberate
|
|
4
|
+
machine-readable contract (stages, ``### Issue:`` specs, ``Depends on:`` /
|
|
5
|
+
``yolo:`` / ``Published: #<M>`` lines). This module encodes that contract
|
|
6
|
+
once, so ``issue-flow agent epic-status`` (and later ``agent queue``) can
|
|
7
|
+
give deterministic answers instead of every agent re-parsing markdown by
|
|
8
|
+
hand. Read-only: nothing here writes files or talks to GitHub.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_TITLE_RE = re.compile(r"^#\s+Epic\s+#(\d+):\s*(.*)$")
|
|
18
|
+
_STATUS_RE = re.compile(r"^Status:\s*(\w+)\s*$", re.IGNORECASE)
|
|
19
|
+
# "## Stage 1 — title" (em dash, hyphen, or colon separators all accepted).
|
|
20
|
+
_STAGE_RE = re.compile(r"^##\s+Stage\s+(\d+)\s*(?:[—:-]\s*)?(.*)$")
|
|
21
|
+
_LATER_RE = re.compile(r"^##\s+Later\b")
|
|
22
|
+
_ISSUE_RE = re.compile(r"^###\s+Issue:\s*(.*)$")
|
|
23
|
+
_PUBLISHED_RE = re.compile(r"^-\s*Published:\s*#(\d+)\s*$")
|
|
24
|
+
_DEPENDS_RE = re.compile(r"^-\s*Depends on:\s*(.*)$")
|
|
25
|
+
_YOLO_RE = re.compile(r"^-\s*yolo:\s*(yes|no)\b", re.IGNORECASE)
|
|
26
|
+
_DEP_NUMBER_RE = re.compile(r"#(\d+)")
|
|
27
|
+
_DEP_PLACEHOLDER_RE = re.compile(r"stage\s+(\d+)\s+issue\s+(\d+)", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class IssueSpec:
|
|
32
|
+
"""One ``### Issue:`` block inside a stage."""
|
|
33
|
+
|
|
34
|
+
title: str
|
|
35
|
+
stage: int
|
|
36
|
+
index: int # 1-based position within the stage
|
|
37
|
+
published: int | None = None
|
|
38
|
+
depends_on: list[int] = field(default_factory=list)
|
|
39
|
+
placeholder_deps: list[tuple[int, int]] = field(default_factory=list)
|
|
40
|
+
yolo: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Stage:
|
|
45
|
+
index: int
|
|
46
|
+
title: str
|
|
47
|
+
issues: list[IssueSpec] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class EpicPlan:
|
|
52
|
+
number: int | None
|
|
53
|
+
title: str
|
|
54
|
+
status: str # "draft" | "confirmed" | anything else found
|
|
55
|
+
stages: list[Stage] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def resolve_placeholders(self) -> None:
|
|
58
|
+
"""Fold ``stage <j> issue <k>`` placeholders into ``depends_on``
|
|
59
|
+
wherever the referenced spec has been published."""
|
|
60
|
+
by_position = {
|
|
61
|
+
(spec.stage, spec.index): spec
|
|
62
|
+
for stage in self.stages
|
|
63
|
+
for spec in stage.issues
|
|
64
|
+
}
|
|
65
|
+
for stage in self.stages:
|
|
66
|
+
for spec in stage.issues:
|
|
67
|
+
remaining: list[tuple[int, int]] = []
|
|
68
|
+
for position in spec.placeholder_deps:
|
|
69
|
+
target = by_position.get(position)
|
|
70
|
+
if target is not None and target.published is not None:
|
|
71
|
+
if target.published not in spec.depends_on:
|
|
72
|
+
spec.depends_on.append(target.published)
|
|
73
|
+
else:
|
|
74
|
+
remaining.append(position)
|
|
75
|
+
spec.placeholder_deps = remaining
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_epic_plan(path: Path) -> EpicPlan | None:
|
|
79
|
+
"""Parse an epic plan file, or ``None`` when unreadable.
|
|
80
|
+
|
|
81
|
+
Lenient by design: unknown lines are ignored, so hand-edited prose never
|
|
82
|
+
breaks parsing — only the marker lines above carry meaning.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
text = path.read_text(encoding="utf-8")
|
|
86
|
+
except OSError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
plan = EpicPlan(number=None, title="", status="draft")
|
|
90
|
+
current_stage: Stage | None = None
|
|
91
|
+
current_issue: IssueSpec | None = None
|
|
92
|
+
in_later = False
|
|
93
|
+
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
|
|
97
|
+
title_match = _TITLE_RE.match(stripped)
|
|
98
|
+
if title_match:
|
|
99
|
+
plan.number = int(title_match.group(1))
|
|
100
|
+
plan.title = title_match.group(2).strip()
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
status_match = _STATUS_RE.match(stripped)
|
|
104
|
+
if status_match:
|
|
105
|
+
plan.status = status_match.group(1).lower()
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if _LATER_RE.match(stripped):
|
|
109
|
+
in_later = True
|
|
110
|
+
current_stage = None
|
|
111
|
+
current_issue = None
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
stage_match = _STAGE_RE.match(stripped)
|
|
115
|
+
if stage_match:
|
|
116
|
+
in_later = False
|
|
117
|
+
current_stage = Stage(
|
|
118
|
+
index=int(stage_match.group(1)),
|
|
119
|
+
title=stage_match.group(2).strip(),
|
|
120
|
+
)
|
|
121
|
+
plan.stages.append(current_stage)
|
|
122
|
+
current_issue = None
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if in_later or current_stage is None:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
issue_match = _ISSUE_RE.match(stripped)
|
|
129
|
+
if issue_match:
|
|
130
|
+
current_issue = IssueSpec(
|
|
131
|
+
title=issue_match.group(1).strip(),
|
|
132
|
+
stage=current_stage.index,
|
|
133
|
+
index=len(current_stage.issues) + 1,
|
|
134
|
+
)
|
|
135
|
+
current_stage.issues.append(current_issue)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if current_issue is None:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
published_match = _PUBLISHED_RE.match(stripped)
|
|
142
|
+
if published_match:
|
|
143
|
+
current_issue.published = int(published_match.group(1))
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
depends_match = _DEPENDS_RE.match(stripped)
|
|
147
|
+
if depends_match:
|
|
148
|
+
payload = depends_match.group(1)
|
|
149
|
+
current_issue.depends_on = [
|
|
150
|
+
int(number) for number in _DEP_NUMBER_RE.findall(payload)
|
|
151
|
+
]
|
|
152
|
+
current_issue.placeholder_deps = [
|
|
153
|
+
(int(j), int(k)) for j, k in _DEP_PLACEHOLDER_RE.findall(payload)
|
|
154
|
+
]
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
yolo_match = _YOLO_RE.match(stripped)
|
|
158
|
+
if yolo_match:
|
|
159
|
+
current_issue.yolo = yolo_match.group(1).lower() == "yes"
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
plan.resolve_placeholders()
|
|
163
|
+
return plan
|