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.
Files changed (74) hide show
  1. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/PKG-INFO +1 -1
  2. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/pyproject.toml +1 -1
  3. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/agent.py +393 -0
  4. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/cli.py +80 -0
  5. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/config.py +3 -0
  6. issue_flow-0.4.3.post3/src/issue_flow/epicplan.py +163 -0
  7. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/gitutils.py +74 -0
  8. issue_flow-0.4.3.post3/src/issue_flow/queueplan.py +126 -0
  9. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/step_profiles.toml +2 -0
  10. issue_flow-0.4.3.post3/src/issue_flow/templates/commands/iflow-cycle.md.j2 +33 -0
  11. issue_flow-0.4.3.post3/src/issue_flow/templates/commands/iflow-epic.md.j2 +35 -0
  12. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow.md.j2 +1 -1
  13. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/docs/issue-workflow.md.j2 +3 -1
  14. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/_body.md.j2 +4 -0
  15. issue_flow-0.4.3.post3/src/issue_flow/templates/skills/iflow_cycle/SKILL.md.j2 +65 -0
  16. issue_flow-0.4.3.post3/src/issue_flow/templates/skills/iflow_epic/SKILL.md.j2 +101 -0
  17. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_iflow/SKILL.md.j2 +1 -1
  18. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templating.py +4 -0
  19. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/LICENSE +0 -0
  20. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/README.md +0 -0
  21. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/__init__.py +0 -0
  22. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/dependencies.py +0 -0
  23. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/editors.py +0 -0
  24. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/graphify.py +0 -0
  25. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/init.py +0 -0
  26. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/modes.py +0 -0
  27. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/modes.toml +0 -0
  28. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/project.py +0 -0
  29. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/py.typed +0 -0
  30. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/step_profiles.py +0 -0
  31. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/__init__.py +0 -0
  32. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/__init__.py +0 -0
  33. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-archive.md.j2 +0 -0
  34. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-cleanup.md.j2 +0 -0
  35. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-close.md.j2 +0 -0
  36. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-fix.md.j2 +0 -0
  37. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-graphify.md.j2 +0 -0
  38. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-init.md.j2 +0 -0
  39. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-pause.md.j2 +0 -0
  40. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-pick.md.j2 +0 -0
  41. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-plan.md.j2 +0 -0
  42. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-start.md.j2 +0 -0
  43. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-status.md.j2 +0 -0
  44. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/commands/iflow-yolo.md.j2 +0 -0
  45. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/designs/python-quality-tools.md.j2 +0 -0
  46. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/docs/__init__.py +0 -0
  47. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/docs/this-project.md.j2 +0 -0
  48. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/AGENTS.md.j2 +0 -0
  49. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/CLAUDE.md.j2 +0 -0
  50. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/__init__.py +0 -0
  51. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 +0 -0
  52. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/_invocation_forms.md.j2 +0 -0
  53. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/_model_directive.md.j2 +0 -0
  54. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/_resolve_project_root.md.j2 +0 -0
  55. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/caveman/SKILL.md.j2 +0 -0
  56. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/grill_me/SKILL.md.j2 +0 -0
  57. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_archive/SKILL.md.j2 +0 -0
  58. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 +0 -0
  59. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_close/SKILL.md.j2 +0 -0
  60. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_comments/SKILL.md.j2 +0 -0
  61. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_fix/SKILL.md.j2 +0 -0
  62. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_graphify/SKILL.md.j2 +0 -0
  63. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_history_update/SKILL.md.j2 +0 -0
  64. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_init/SKILL.md.j2 +0 -0
  65. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_pause/SKILL.md.j2 +0 -0
  66. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_pick/SKILL.md.j2 +0 -0
  67. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_plan/SKILL.md.j2 +0 -0
  68. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_start/SKILL.md.j2 +0 -0
  69. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_status/SKILL.md.j2 +0 -0
  70. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_version_bump/SKILL.md.j2 +0 -0
  71. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/skills/iflow_yolo/SKILL.md.j2 +0 -0
  72. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/templates/tools/README.md.j2 +0 -0
  73. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/tracking.py +0 -0
  74. {issue_flow-0.4.3 → issue_flow-0.4.3.post3}/src/issue_flow/versionplan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: issue-flow
3
- Version: 0.4.3
3
+ Version: 0.4.3.post3
4
4
  Summary: Agents should behave. Let them follow the issue flow.
5
5
  Keywords: cursor,ai,agents,issue-tracking,workflow
6
6
  Author: jepegit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "issue-flow"
3
- version = "0.4.3"
3
+ version = "0.4.3.post3"
4
4
  description = "Agents should behave. Let them follow the issue flow."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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