issue-flow 0.5.2__tar.gz → 0.5.4__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 (121) hide show
  1. {issue_flow-0.5.2 → issue_flow-0.5.4}/PKG-INFO +1 -1
  2. {issue_flow-0.5.2 → issue_flow-0.5.4}/pyproject.toml +1 -1
  3. {issue_flow-0.5.2 → issue_flow-0.5.4}/pyproject.toml.orig +1 -1
  4. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/agent.py +319 -8
  5. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/cli.py +153 -4
  6. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/config.py +16 -0
  7. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/config_ops.py +1 -0
  8. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/gitutils.py +122 -0
  9. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/history.py +171 -21
  10. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/init.py +22 -13
  11. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/modes.py +26 -0
  12. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/project.py +53 -0
  13. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-cleanup.md.j2 +6 -3
  14. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-close.md.j2 +14 -3
  15. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-pick.md.j2 +1 -1
  16. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/docs/issue-workflow.md.j2 +5 -3
  17. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/rules/_body.md.j2 +4 -2
  18. issue_flow-0.5.4/src/issue_flow/templates/skills/_default_sync_recover.md.j2 +11 -0
  19. issue_flow-0.5.4/src/issue_flow/templates/skills/_worktree_start.md.j2 +8 -0
  20. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 +8 -3
  21. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_close/SKILL.md.j2 +11 -5
  22. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_cycle/SKILL.md.j2 +1 -1
  23. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_doctor/SKILL.md.j2 +5 -2
  24. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_epic/SKILL.md.j2 +2 -1
  25. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_history_update/SKILL.md.j2 +25 -7
  26. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_init/SKILL.md.j2 +1 -0
  27. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_yolo/SKILL.md.j2 +1 -1
  28. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/user_global.py +2 -6
  29. issue_flow-0.5.2/src/issue_flow/templates/skills/_worktree_start.md.j2 +0 -7
  30. {issue_flow-0.5.2 → issue_flow-0.5.4}/LICENSE +0 -0
  31. {issue_flow-0.5.2 → issue_flow-0.5.4}/README.md +0 -0
  32. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/__init__.py +0 -0
  33. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/console_io.py +0 -0
  34. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/convert.py +0 -0
  35. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/dependencies.py +0 -0
  36. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/editors.py +0 -0
  37. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/epicplan.py +0 -0
  38. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/graphify.py +0 -0
  39. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/modes.toml +0 -0
  40. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/py.typed +0 -0
  41. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/queueplan.py +0 -0
  42. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/readiness.py +0 -0
  43. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/skill_ownership.py +0 -0
  44. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/step_profiles.py +0 -0
  45. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/step_profiles.toml +0 -0
  46. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/surfaces.py +0 -0
  47. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/sync.py +0 -0
  48. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/__init__.py +0 -0
  49. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/__init__.py +0 -0
  50. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-archive.md.j2 +0 -0
  51. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-auto.md.j2 +0 -0
  52. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-build.md.j2 +0 -0
  53. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-capture.md.j2 +0 -0
  54. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-cycle.md.j2 +0 -0
  55. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-doctor.md.j2 +0 -0
  56. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-epic.md.j2 +0 -0
  57. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-fix.md.j2 +0 -0
  58. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-graphify.md.j2 +0 -0
  59. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-init.md.j2 +0 -0
  60. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-issue.md.j2 +0 -0
  61. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-ops.md.j2 +0 -0
  62. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-pause.md.j2 +0 -0
  63. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-plan.md.j2 +0 -0
  64. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-pr-sync.md.j2 +0 -0
  65. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-review.md.j2 +0 -0
  66. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-setup.md.j2 +0 -0
  67. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-split.md.j2 +0 -0
  68. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-status.md.j2 +0 -0
  69. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow-yolo.md.j2 +0 -0
  70. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/commands/iflow.md.j2 +0 -0
  71. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/designs/essential-tests.md.j2 +0 -0
  72. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/designs/python-quality-tools.md.j2 +0 -0
  73. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/designs/test-registry.md.j2 +0 -0
  74. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/docs/__init__.py +0 -0
  75. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/docs/this-project.md.j2 +0 -0
  76. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/rules/AGENTS.md.j2 +0 -0
  77. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/rules/CLAUDE.md.j2 +0 -0
  78. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/rules/__init__.py +0 -0
  79. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/rules/issueflow-rules.mdc.j2 +0 -0
  80. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_essential_tests_close_sanity.md.j2 +0 -0
  81. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_essential_tests_review.md.j2 +0 -0
  82. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_invocation_forms.md.j2 +0 -0
  83. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_model_directive.md.j2 +0 -0
  84. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_pstack_LICENSE.txt +0 -0
  85. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_resolve_project_root.md.j2 +0 -0
  86. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/_worktree_close_remove.md.j2 +0 -0
  87. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/caveman/SKILL.md.j2 +0 -0
  88. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/gh_ci/SKILL.md.j2 +0 -0
  89. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/grill_me/SKILL.md.j2 +0 -0
  90. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_archive/SKILL.md.j2 +0 -0
  91. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_auto/SKILL.md.j2 +0 -0
  92. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_build/SKILL.md.j2 +0 -0
  93. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_capture/SKILL.md.j2 +0 -0
  94. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_comments/SKILL.md.j2 +0 -0
  95. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_fix/SKILL.md.j2 +0 -0
  96. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_graphify/SKILL.md.j2 +0 -0
  97. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_iflow/SKILL.md.j2 +0 -0
  98. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_issue/SKILL.md.j2 +0 -0
  99. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_ops/SKILL.md.j2 +0 -0
  100. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_pause/SKILL.md.j2 +0 -0
  101. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_pick/SKILL.md.j2 +0 -0
  102. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_plan/SKILL.md.j2 +0 -0
  103. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_pr_sync/SKILL.md.j2 +0 -0
  104. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_review/SKILL.md.j2 +0 -0
  105. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_setup/SKILL.md.j2 +0 -0
  106. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_split/SKILL.md.j2 +0 -0
  107. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_status/SKILL.md.j2 +0 -0
  108. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/iflow_version_bump/SKILL.md.j2 +0 -0
  109. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_blast_radius/SKILL.md.j2 +0 -0
  110. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_bro/SKILL.md.j2 +0 -0
  111. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_principle_fix_root_causes/SKILL.md.j2 +0 -0
  112. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_principle_prove_it_works/SKILL.md.j2 +0 -0
  113. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_principle_subtract_before_you_add/SKILL.md.j2 +0 -0
  114. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_principle_test_behavior_not_implementation/SKILL.md.j2 +0 -0
  115. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_tdd/SKILL.md.j2 +0 -0
  116. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_technical_writing/SKILL.md.j2 +0 -0
  117. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/skills/pstack_unslop/SKILL.md.j2 +0 -0
  118. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templates/tools/README.md.j2 +0 -0
  119. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/templating.py +0 -0
  120. {issue_flow-0.5.2 → issue_flow-0.5.4}/src/issue_flow/tracking.py +0 -0
  121. {issue_flow-0.5.2 → issue_flow-0.5.4}/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.5.2
3
+ Version: 0.5.4
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.5.2"
3
+ version = "0.5.4"
4
4
  description = "Agents should behave. Let them follow the issue flow."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "issue-flow"
3
- version = "0.5.2"
3
+ version = "0.5.4"
4
4
  description = "Agents should behave. Let them follow the issue flow."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,9 +2,9 @@
2
2
 
3
3
  These functions back ``issue-flow status`` (human-facing, top-level) and the
4
4
  ``issue-flow agent ...`` sub-commands (``state`` / ``preflight`` / ``switchback`` /
5
- ``sync-branch`` / ``branches`` / ``version-plan`` / ``resolve`` / ``open-workspace`` /
6
- ``worktree-add`` / ``worktree-list`` / ``worktree-remove`` / ``sweep`` /
7
- ``archive`` / ``capture`` / ``sub-issue-add``) that exist so AI agents
5
+ ``default-sync`` / ``sync-branch`` / ``branches`` / ``version-plan`` / ``resolve`` /
6
+ ``open-workspace`` / ``worktree-add`` / ``worktree-list`` / ``worktree-remove`` /
7
+ ``sweep`` / ``archive`` / ``capture`` / ``sub-issue-add``) that exist so AI agents
8
8
  can ask the tool for a deterministic answer instead of re-deriving lifecycle
9
9
  state by hand on every run.
10
10
 
@@ -795,6 +795,7 @@ def run_switchback(project_root: Path, console: Console, as_json: bool) -> int:
795
795
  "pulled": False,
796
796
  "in_worktree": False,
797
797
  "dirty_paths": [],
798
+ "default_sync": None,
798
799
  "notes": notes,
799
800
  }
800
801
 
@@ -849,13 +850,24 @@ def run_switchback(project_root: Path, console: Console, as_json: bool) -> int:
849
850
 
850
851
  ok, error = gitutils.pull_ff_only(project_root)
851
852
  payload["pulled"] = ok
853
+ sync = gitutils.classify_default_sync(
854
+ project_root,
855
+ issueflows_dir=Settings().issueflows_dir,
856
+ default=default,
857
+ fetch=False,
858
+ )
859
+ payload["default_sync"] = sync
852
860
  if not ok:
853
861
  notes.append(
854
- f"git pull --ff-only refused: {error} — reconcile manually before "
855
- "continuing."
862
+ f"git pull --ff-only refused: {error} — do not rebase, force-push, "
863
+ "or push default to skip CI."
856
864
  )
865
+ notes.extend(_default_sync_note_lines(sync))
857
866
  return emit(1)
858
867
 
868
+ if sync.get("ahead"):
869
+ notes.extend(_default_sync_note_lines(sync))
870
+
859
871
  return emit(0)
860
872
 
861
873
 
@@ -878,6 +890,100 @@ def _render_switchback_text(
878
890
  console.print(f" [{style}]{escape(note)}[/{style}]")
879
891
 
880
892
 
893
+ # ---------------------------------------------------------------------------
894
+ # agent default-sync (issue #303)
895
+ # ---------------------------------------------------------------------------
896
+
897
+
898
+ def run_default_sync(project_root: Path, console: Console, as_json: bool) -> int:
899
+ """Classify unique commits on home default vs origin — no mutate."""
900
+ notes: list[str] = []
901
+ if not gitutils.git_available():
902
+ payload = {
903
+ "default_branch": None,
904
+ "ahead": None,
905
+ "behind": None,
906
+ "ff_possible": False,
907
+ "class": "unknown",
908
+ "action": "unknown",
909
+ "commits": [],
910
+ "tree_paths": [],
911
+ "never": list(gitutils.DEFAULT_SYNC_NEVER),
912
+ "notes": ["git is not on PATH"],
913
+ }
914
+ if as_json:
915
+ _emit_json(console, payload)
916
+ else:
917
+ console.print("[red]error[/red] git is not on PATH")
918
+ return 1
919
+
920
+ payload = gitutils.classify_default_sync(
921
+ project_root,
922
+ issueflows_dir=Settings().issueflows_dir,
923
+ )
924
+ notes.extend(payload.get("notes") or [])
925
+ payload["notes"] = notes
926
+ if as_json:
927
+ _emit_json(console, payload)
928
+ return 0
929
+ _render_default_sync_text(console, payload)
930
+ return 0
931
+
932
+
933
+ def _default_sync_note_lines(sync: dict[str, Any]) -> list[str]:
934
+ """Short notes skills can print after an ff-only refusal or ahead-only home."""
935
+ lines = [
936
+ f"default-sync: action={sync.get('action')} class={sync.get('class')} "
937
+ f"ahead={sync.get('ahead')} behind={sync.get('behind')}"
938
+ ]
939
+ for commit in sync.get("commits") or []:
940
+ mark = " (merge)" if commit.get("is_merge") else ""
941
+ paths = ", ".join(commit.get("paths") or []) or "(no paths)"
942
+ lines.append(f" {commit.get('sha')} {commit.get('subject')}{mark} — {paths}")
943
+ tree = sync.get("tree_paths") or []
944
+ if tree:
945
+ lines.append(" tree: " + ", ".join(tree))
946
+ action = sync.get("action")
947
+ if action == "tracking_pr":
948
+ lines.append(
949
+ "offer: merge origin/<default> or cherry-pick onto a chore branch, "
950
+ "then open a tiny PR — never push default directly."
951
+ )
952
+ elif action == "replay_tracking":
953
+ lines.append(
954
+ "offer: replay the tracking commit onto origin/<default> "
955
+ "(chore branch + PR). Do not stack another merge."
956
+ )
957
+ elif action == "stop_product":
958
+ lines.append(
959
+ "stop: unique commits touch product / lock / HISTORY. "
960
+ "User decides. Do not merge onto default."
961
+ )
962
+ elif action == "report_ahead":
963
+ lines.append(
964
+ "home default is ahead of origin; report unique commits. "
965
+ "Do not silent-push default."
966
+ )
967
+ elif action == "ff_only":
968
+ lines.append("ff-only is safe (behind only).")
969
+ never = sync.get("never") or list(gitutils.DEFAULT_SYNC_NEVER)
970
+ lines.append("never: " + "; ".join(never))
971
+ return lines
972
+
973
+
974
+ def _render_default_sync_text(console: Console, payload: dict[str, Any]) -> None:
975
+ console.print(
976
+ f"[bold]action[/bold] {escape(str(payload.get('action')))} "
977
+ f"class={escape(str(payload.get('class')))} "
978
+ f"ahead={payload.get('ahead')} behind={payload.get('behind')} "
979
+ f"ff_possible={payload.get('ff_possible')}"
980
+ )
981
+ for line in _default_sync_note_lines(payload)[1:]:
982
+ console.print(f" {escape(line)}")
983
+ for note in payload.get("notes") or []:
984
+ console.print(f" [dim]{escape(note)}[/dim]")
985
+
986
+
881
987
  # ---------------------------------------------------------------------------
882
988
  # agent sync-branch
883
989
  # ---------------------------------------------------------------------------
@@ -2809,7 +2915,15 @@ def run_workspace_update(
2809
2915
  f"`issue-flow workspace init` from the workspace root first."
2810
2916
  )
2811
2917
 
2812
- member_roots = workspace.member_roots()
2918
+ seen_roots: set[Path] = set()
2919
+ member_pairs: list[tuple[str, Path]] = []
2920
+ for name, root in zip(workspace.members, workspace.member_roots(), strict=True):
2921
+ resolved = root.resolve()
2922
+ if resolved in seen_roots:
2923
+ continue
2924
+ seen_roots.add(resolved)
2925
+ member_pairs.append((name, resolved))
2926
+ member_roots = [root for _, root in member_pairs]
2813
2927
  if not member_roots:
2814
2928
  return _fail(
2815
2929
  f"no scaffolded member repos found under {workspace.root} — run "
@@ -2844,7 +2958,7 @@ def run_workspace_update(
2844
2958
  ok_count = 0
2845
2959
  fail_count = 0
2846
2960
 
2847
- for name, root in zip(workspace.members, member_roots, strict=True):
2961
+ for name, root in member_pairs:
2848
2962
  entry: dict[str, Any] = {"name": name, "path": str(root)}
2849
2963
  try:
2850
2964
  _run_member_update(root)
@@ -2887,6 +3001,202 @@ def run_workspace_update(
2887
3001
  return 0 if fail_count == 0 else 1
2888
3002
 
2889
3003
 
3004
+ # ---------------------------------------------------------------------------
3005
+ # agent apply-changelog
3006
+ # ---------------------------------------------------------------------------
3007
+
3008
+
3009
+ def _status_path_for_issue(
3010
+ project_root: Path, settings: Settings, number: int
3011
+ ) -> Path | None:
3012
+ """``issue<N>_status.md`` in current-issues, then solved (issue #288)."""
3013
+ name = f"issue{number}_status.md"
3014
+ base = project_root / settings.issueflows_dir
3015
+ for folder in (settings.current_issues_folder, settings.solved_folder):
3016
+ path = base / folder / name
3017
+ if path.is_file():
3018
+ return path
3019
+ return None
3020
+
3021
+
3022
+ def run_apply_changelog(
3023
+ project_root: Path,
3024
+ console: Console,
3025
+ issue_number: int,
3026
+ as_json: bool,
3027
+ ) -> int:
3028
+ """Apply a deferred changelog bullet on the default branch (issue #288).
3029
+
3030
+ Reads ``### Deferred changelog`` from ``issue<N>_status.md`` (current, then
3031
+ solved) and writes ``Settings.history_file``. Refuses on an issue branch.
3032
+ No-op when the file is missing, the close step chose ``nohistory``, or the
3033
+ bullet is already present.
3034
+ """
3035
+ from datetime import date
3036
+
3037
+ settings = Settings()
3038
+ changelog_name = settings.history_file
3039
+ notes: list[str] = []
3040
+ payload: dict[str, Any] = {
3041
+ "ok": False,
3042
+ "action": "none",
3043
+ "reason": None,
3044
+ "issue": issue_number,
3045
+ "history_file": changelog_name,
3046
+ "status_file": None,
3047
+ "bullet": None,
3048
+ "planned_version": None,
3049
+ "branch": None,
3050
+ "default_branch": None,
3051
+ "notes": notes,
3052
+ }
3053
+
3054
+ def emit(exit_code: int) -> int:
3055
+ payload["ok"] = exit_code == 0
3056
+ if as_json:
3057
+ _emit_json(console, payload)
3058
+ else:
3059
+ _render_apply_changelog_text(console, payload, exit_code)
3060
+ return exit_code
3061
+
3062
+ if not gitutils.git_available():
3063
+ notes.append("git is not available.")
3064
+ payload["reason"] = "git_unavailable"
3065
+ payload["action"] = "refused"
3066
+ return emit(1)
3067
+
3068
+ current = gitutils.current_branch(project_root)
3069
+ default = gitutils.default_branch(project_root)
3070
+ payload["branch"] = current
3071
+ payload["default_branch"] = default
3072
+ if current is None or current != default:
3073
+ notes.append(
3074
+ f"apply-changelog runs on the default branch only "
3075
+ f"(now on {current or 'detached'}, default {default})."
3076
+ )
3077
+ payload["reason"] = "not_default_branch"
3078
+ payload["action"] = "refused"
3079
+ return emit(1)
3080
+
3081
+ status_path = _status_path_for_issue(project_root, settings, issue_number)
3082
+ if status_path is None:
3083
+ notes.append(
3084
+ f"no issue{issue_number}_status.md under "
3085
+ f"{settings.current_issues_folder} or {settings.solved_folder}."
3086
+ )
3087
+ payload["reason"] = "no_status_file"
3088
+ payload["action"] = "noop"
3089
+ return emit(0)
3090
+
3091
+ payload["status_file"] = str(
3092
+ status_path.relative_to(project_root)
3093
+ if status_path.is_relative_to(project_root)
3094
+ else status_path
3095
+ )
3096
+ try:
3097
+ status_text = status_path.read_text(encoding="utf-8")
3098
+ except OSError as exc:
3099
+ notes.append(f"could not read status file: {exc}")
3100
+ payload["reason"] = "status_unreadable"
3101
+ payload["action"] = "refused"
3102
+ return emit(1)
3103
+
3104
+ deferred = history.parse_deferred_changelog(status_text)
3105
+ if deferred is None:
3106
+ notes.append("no ### Deferred changelog section.")
3107
+ payload["reason"] = "no_deferred_section"
3108
+ payload["action"] = "noop"
3109
+ return emit(0)
3110
+ if deferred.skipped:
3111
+ notes.append("deferred section is nohistory — skip.")
3112
+ payload["reason"] = "nohistory"
3113
+ payload["action"] = "noop"
3114
+ return emit(0)
3115
+ if not deferred.bullet:
3116
+ notes.append("deferred section has no bullet.")
3117
+ payload["reason"] = "no_bullet"
3118
+ payload["action"] = "noop"
3119
+ return emit(0)
3120
+
3121
+ payload["bullet"] = deferred.bullet
3122
+ payload["planned_version"] = deferred.planned_version
3123
+
3124
+ history_path = project_root / changelog_name
3125
+ if not history_path.is_file():
3126
+ notes.append(f"no {changelog_name} at the project root — skip.")
3127
+ payload["reason"] = "missing_history_file"
3128
+ payload["action"] = "noop"
3129
+ return emit(0)
3130
+
3131
+ try:
3132
+ text = history_path.read_text(encoding="utf-8")
3133
+ except OSError as exc:
3134
+ notes.append(f"could not read {changelog_name}: {exc}")
3135
+ payload["reason"] = "history_unreadable"
3136
+ payload["action"] = "refused"
3137
+ return emit(1)
3138
+
3139
+ if history.changelog_has_bullet(text, deferred.bullet):
3140
+ notes.append("bullet already present.")
3141
+ payload["reason"] = "already_present"
3142
+ payload["action"] = "noop"
3143
+ return emit(0)
3144
+
3145
+ try:
3146
+ updated = history.append_unreleased_bullet(text, deferred.bullet)
3147
+ action = "appended"
3148
+ if deferred.planned_version:
3149
+ updated = history.promote_unreleased(
3150
+ updated, deferred.planned_version, date.today().isoformat()
3151
+ )
3152
+ action = "promoted"
3153
+ except history.MissingUnreleased:
3154
+ notes.append(f"{changelog_name} has no ## [Unreleased] heading.")
3155
+ payload["reason"] = "no_unreleased_section"
3156
+ payload["action"] = "refused"
3157
+ return emit(1)
3158
+
3159
+ if updated == text:
3160
+ notes.append("no change after apply.")
3161
+ payload["reason"] = "unchanged"
3162
+ payload["action"] = "noop"
3163
+ return emit(0)
3164
+
3165
+ try:
3166
+ history_path.write_text(updated, encoding="utf-8")
3167
+ except OSError as exc:
3168
+ notes.append(f"could not write {changelog_name}: {exc}")
3169
+ payload["reason"] = "history_unwritable"
3170
+ payload["action"] = "refused"
3171
+ return emit(1)
3172
+
3173
+ payload["reason"] = action
3174
+ payload["action"] = action
3175
+ notes.append(f"wrote {changelog_name} ({action}).")
3176
+ return emit(0)
3177
+
3178
+
3179
+ def _render_apply_changelog_text(
3180
+ console: Console, payload: dict[str, Any], exit_code: int
3181
+ ) -> None:
3182
+ action = payload.get("action") or "none"
3183
+ reason = payload.get("reason") or ""
3184
+ issue = payload.get("issue")
3185
+ if exit_code == 0:
3186
+ console.print(
3187
+ f"[green]ok[/green] apply-changelog #{issue} "
3188
+ f"[bold]{escape(str(action))}[/bold]"
3189
+ + (f" ({escape(str(reason))})" if reason and reason != action else "")
3190
+ )
3191
+ else:
3192
+ console.print(
3193
+ f"[red]refused[/red] apply-changelog #{issue}: "
3194
+ f"{escape(str(reason) or action)}"
3195
+ )
3196
+ for note in payload.get("notes") or []:
3197
+ console.print(f" [dim]{escape(str(note))}[/dim]")
3198
+
3199
+
2890
3200
  # ---------------------------------------------------------------------------
2891
3201
  # agent sweep
2892
3202
  # ---------------------------------------------------------------------------
@@ -3531,7 +3841,8 @@ def _print_config_guide(console: Console, cfg_path: Path) -> None:
3531
3841
  "[bold]auto_close[/bold] / "
3532
3842
  "[bold]auto_plan[/bold] / [bold]auto_build[/bold] / "
3533
3843
  "[bold]early_pr[/bold] / [bold]fix_auto_name[/bold]; "
3534
- "[bold]confirm_version_bump[/bold] / [bold]confirm_changelog_update[/bold]; "
3844
+ "[bold]confirm_version_bump[/bold] / [bold]confirm_changelog_update[/bold] / "
3845
+ "[bold]defer_changelog[/bold]; "
3535
3846
  "[bold]ruff_autofix[/bold]; [bold]essential_tests[/bold] / "
3536
3847
  "[bold]test_runner[/bold] / [bold]essential_marker[/bold] / "
3537
3848
  "[bold]essential_review[/bold]: skill-behaviour toggles; re-run "
@@ -22,10 +22,10 @@ agent_app = typer.Typer(
22
22
  "Agent-facing helpers that read the .issueflows/ tree and git/gh so "
23
23
  "AI agents get deterministic answers instead of re-deriving lifecycle "
24
24
  "state by hand. All are read-only except `sweep`, `archive`, `capture`, "
25
- "`switchback`, `sync-branch`, `pr-sync`, `repair`, `label-apply`, "
25
+ "`switchback`, `sync-branch`, `pr-sync`, `apply-changelog`, `repair`, `label-apply`, "
26
26
  "`open-workspace --open`, `worktree-add`, and `worktree-remove`. "
27
- "`branches` (remote) and `local-branches` (local) only classify: "
28
- "every delete stays in `/iflow-cleanup`."
27
+ "`branches` (remote), `local-branches` (local), and `default-sync` only classify: "
28
+ "every delete stays in `/iflow-cleanup`; `default-sync` never mutates."
29
29
  ),
30
30
  )
31
31
 
@@ -263,10 +263,26 @@ def update(
263
263
  json_output: bool = typer.Option(
264
264
  False, "--json", help="Emit a machine-readable JSON object (with --all)."
265
265
  ),
266
+ include_workspace: bool = typer.Option(
267
+ False,
268
+ "--workspace",
269
+ help=(
270
+ "With --all, also union members of the nearest "
271
+ "issueflow-workspace.toml (unique by resolved path). "
272
+ "Without --all this flag is an error."
273
+ ),
274
+ ),
266
275
  ) -> None:
267
276
  """Refresh packaged editor commands, rules, and workflow doc from this package."""
268
277
  from issue_flow.init import run_update, run_update_all
269
278
 
279
+ if include_workspace and not all_roots:
280
+ _console.print(
281
+ "[red]error[/red] --workspace requires --all "
282
+ "(union registry + nearest workspace members)."
283
+ )
284
+ raise typer.Exit(code=2)
285
+
270
286
  if all_roots:
271
287
  raise typer.Exit(
272
288
  code=run_update_all(
@@ -274,6 +290,8 @@ def update(
274
290
  editors=editor,
275
291
  force=force,
276
292
  as_json=json_output,
293
+ include_workspace=include_workspace,
294
+ workspace_start=project_dir,
277
295
  )
278
296
  )
279
297
 
@@ -522,6 +540,25 @@ def agent_switchback(
522
540
  raise typer.Exit(code=run_switchback(project_dir, _console, json_output))
523
541
 
524
542
 
543
+ @agent_app.command("default-sync")
544
+ def agent_default_sync(
545
+ project_dir: Path = _PROJECT_DIR_OPTION,
546
+ json_output: bool = typer.Option(
547
+ False, "--json", help="Emit a machine-readable JSON object."
548
+ ),
549
+ ) -> None:
550
+ """Classify unique commits on home default vs origin (no mutate).
551
+
552
+ The mechanical half of pick / cleanup / switchback recovery when
553
+ ``git pull --ff-only`` cannot reconcile unpushed home commits with a
554
+ squash on origin. Prints ahead/behind, commit onelines + paths, and a
555
+ recommended ``action``. Never rebases, force-pushes, or pushes default.
556
+ """
557
+ from issue_flow.agent import run_default_sync
558
+
559
+ raise typer.Exit(code=run_default_sync(project_dir, _console, json_output))
560
+
561
+
525
562
  @agent_app.command("sync-branch")
526
563
  def agent_sync_branch(
527
564
  project_dir: Path = _PROJECT_DIR_OPTION,
@@ -552,6 +589,35 @@ def agent_sync_branch(
552
589
  raise typer.Exit(code=run_sync_branch(project_dir, _console, strategy, json_output))
553
590
 
554
591
 
592
+ @agent_app.command("apply-changelog")
593
+ def agent_apply_changelog(
594
+ issue: int = typer.Option(
595
+ ...,
596
+ "--issue",
597
+ "-n",
598
+ help="GitHub issue number whose deferred changelog bullet to apply.",
599
+ ),
600
+ project_dir: Path = _PROJECT_DIR_OPTION,
601
+ json_output: bool = typer.Option(
602
+ False, "--json", help="Emit a machine-readable JSON object."
603
+ ),
604
+ ) -> None:
605
+ """Apply a deferred changelog bullet on the default branch.
606
+
607
+ Reads ``### Deferred changelog`` from ``issue<N>_status.md`` (current, then
608
+ solved) and writes the project's HISTORY/CHANGELOG file. Refuses unless
609
+ the checkout is on the default branch. No-op when the file is missing,
610
+ the close step chose ``nohistory``, or the bullet is already present.
611
+ Used by ``/iflow-cleanup`` and yolo post-pull when ``defer_changelog`` is
612
+ on (issue #288).
613
+ """
614
+ from issue_flow.agent import run_apply_changelog
615
+
616
+ raise typer.Exit(
617
+ code=run_apply_changelog(project_dir, _console, issue, json_output)
618
+ )
619
+
620
+
555
621
  @agent_app.command("pr-sync")
556
622
  def agent_pr_sync(
557
623
  numbers: list[int] = typer.Argument(
@@ -1201,6 +1267,7 @@ def config_add(
1201
1267
  ``confirm_version_bump``,
1202
1268
  ``ruff_autofix``, ``auto_close``, ``auto_plan``, ``auto_build``,
1203
1269
  ``early_pr``, ``fix_auto_name``, ``confirm_changelog_update``,
1270
+ ``defer_changelog``,
1204
1271
  ``essential_tests``, ``test_runner``, ``essential_marker``,
1205
1272
  ``essential_review``, ``pstack_skills``
1206
1273
  — taking each from its ``ISSUEFLOW_*`` env var when set, else the default.
@@ -1427,7 +1494,10 @@ def workspace_update(
1427
1494
  def register(
1428
1495
  project_dir: Path = typer.Argument(
1429
1496
  default=Path("."),
1430
- help="Project root to add to the user-global registry.",
1497
+ help=(
1498
+ "Project root to add, or the walk start when ``--discover`` "
1499
+ "is set (defaults to current directory)."
1500
+ ),
1431
1501
  exists=True,
1432
1502
  file_okay=False,
1433
1503
  resolve_path=True,
@@ -1435,10 +1505,89 @@ def register(
1435
1505
  json_output: bool = typer.Option(
1436
1506
  False, "--json", help="Emit a machine-readable JSON object."
1437
1507
  ),
1508
+ discover: bool = typer.Option(
1509
+ False,
1510
+ "--discover",
1511
+ help=(
1512
+ "Walk PROJECT_DIR for existing issue-flow scaffolds and offer "
1513
+ "to register them. Never runs from update --all / init / "
1514
+ "workspace update."
1515
+ ),
1516
+ ),
1517
+ yes: bool = typer.Option(
1518
+ False,
1519
+ "--yes",
1520
+ help="With --discover, register candidates without a confirm prompt.",
1521
+ ),
1522
+ max_depth: int = typer.Option(
1523
+ 4,
1524
+ "--max-depth",
1525
+ help="With --discover, maximum directory depth below PROJECT_DIR.",
1526
+ ),
1438
1527
  ) -> None:
1439
1528
  """Add a project root to the user-global registry for ``update --all``."""
1529
+ from issue_flow.config import Settings
1530
+ from issue_flow.project import discover_issueflow_roots
1440
1531
  from issue_flow.user_global import register_root, user_global_registry_path
1441
1532
 
1533
+ if discover:
1534
+ if max_depth < 0:
1535
+ _console.print("[red]error[/red] --max-depth must be >= 0")
1536
+ raise typer.Exit(code=2)
1537
+ settings = Settings()
1538
+ candidates = discover_issueflow_roots(
1539
+ project_dir,
1540
+ issueflows_dir=settings.issueflows_dir,
1541
+ max_depth=max_depth,
1542
+ )
1543
+ if not yes:
1544
+ _console.print(
1545
+ f"[bold]{len(candidates)} scaffold(s) under {project_dir}[/bold]"
1546
+ )
1547
+ for root in candidates:
1548
+ _console.print(f" {root}")
1549
+ if not candidates:
1550
+ payload = {
1551
+ "ok": True,
1552
+ "discover": True,
1553
+ "candidates": [],
1554
+ "added": [],
1555
+ "already": [],
1556
+ "registry": str(user_global_registry_path()),
1557
+ }
1558
+ if json_output:
1559
+ _console.print_json(data=payload)
1560
+ else:
1561
+ _console.print("[dim]nothing to register[/dim]")
1562
+ return
1563
+ if not typer.confirm("Register these roots?", default=False):
1564
+ raise typer.Exit(code=1)
1565
+ added_paths: list[str] = []
1566
+ already_paths: list[str] = []
1567
+ for root in candidates:
1568
+ if register_root(root):
1569
+ added_paths.append(str(root))
1570
+ else:
1571
+ already_paths.append(str(root))
1572
+ payload = {
1573
+ "ok": True,
1574
+ "discover": True,
1575
+ "candidates": [str(root) for root in candidates],
1576
+ "added": added_paths,
1577
+ "already": already_paths,
1578
+ "registry": str(user_global_registry_path()),
1579
+ }
1580
+ if json_output:
1581
+ _console.print_json(data=payload)
1582
+ return
1583
+ for path in added_paths:
1584
+ _console.print(f"[green]registered[/green] {path}")
1585
+ for path in already_paths:
1586
+ _console.print(f"[dim]already registered[/dim] {path}")
1587
+ if not candidates:
1588
+ _console.print("[dim]nothing to register[/dim]")
1589
+ return
1590
+
1442
1591
  added = register_root(project_dir)
1443
1592
  payload = {
1444
1593
  "ok": True,
@@ -18,6 +18,7 @@ from issue_flow.modes import (
18
18
  DEFAULT_AUTO_PLAN,
19
19
  DEFAULT_CHECKS_WATCH_MINUTES,
20
20
  DEFAULT_CONFIRM_CHANGELOG_UPDATE,
21
+ DEFAULT_DEFER_CHANGELOG,
21
22
  DEFAULT_EARLY_PR,
22
23
  DEFAULT_FIX_AUTO_NAME,
23
24
  DEFAULT_LOCKED,
@@ -616,6 +617,16 @@ class Settings:
616
617
  ),
617
618
  )
618
619
 
620
+ def resolve_defer_changelog(self, project_root: Path) -> bool:
621
+ """Resolve whether changelog writes wait until the default branch after merge."""
622
+ persisted = modes_module.read_defer_changelog(self.config_path(project_root))
623
+ if persisted is not None:
624
+ return persisted
625
+ return self.user_global_or(
626
+ "defer_changelog",
627
+ _env_flag("ISSUEFLOW_DEFER_CHANGELOG", default=DEFAULT_DEFER_CHANGELOG),
628
+ )
629
+
619
630
  def resolve_essential_tests(self, project_root: Path) -> bool:
620
631
  """Resolve whether essential-tests skill hooks are enabled."""
621
632
  persisted = modes_module.read_essential_tests(self.config_path(project_root))
@@ -828,6 +839,9 @@ class Settings:
828
839
  "ISSUEFLOW_CONFIRM_CHANGELOG_UPDATE",
829
840
  default=DEFAULT_CONFIRM_CHANGELOG_UPDATE,
830
841
  ),
842
+ "defer_changelog": _env_flag(
843
+ "ISSUEFLOW_DEFER_CHANGELOG", default=DEFAULT_DEFER_CHANGELOG
844
+ ),
831
845
  "essential_tests": _env_flag(
832
846
  "ISSUEFLOW_ESSENTIAL_TESTS", default=DEFAULT_ESSENTIAL_TESTS
833
847
  ),
@@ -882,6 +896,7 @@ class Settings:
882
896
  "confirm_changelog_update": self.resolve_confirm_changelog_update(
883
897
  project_root
884
898
  ),
899
+ "defer_changelog": self.resolve_defer_changelog(project_root),
885
900
  "essential_tests": self.resolve_essential_tests(project_root),
886
901
  "test_runner": self.resolve_test_runner(project_root),
887
902
  "essential_marker": self.resolve_essential_marker(project_root),
@@ -972,6 +987,7 @@ class Settings:
972
987
  "confirm_changelog_update": self.resolve_confirm_changelog_update(
973
988
  project_root
974
989
  ),
990
+ "defer_changelog": self.resolve_defer_changelog(project_root),
975
991
  "essential_tests": self.resolve_essential_tests(project_root),
976
992
  "test_runner": self.resolve_test_runner(project_root),
977
993
  "essential_marker": self.resolve_essential_marker(project_root),