@pennyfarthing/core 11.1.0 → 11.2.0

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 (263) hide show
  1. package/README.md +8 -8
  2. package/package.json +16 -14
  3. package/packages/core/dist/cli/utils/constants.d.ts +1 -1
  4. package/packages/core/dist/cli/utils/constants.d.ts.map +1 -1
  5. package/packages/core/dist/cli/utils/constants.js +2 -1
  6. package/packages/core/dist/cli/utils/constants.js.map +1 -1
  7. package/packages/core/dist/consultation/dialogue-manager.d.ts +75 -0
  8. package/packages/core/dist/consultation/dialogue-manager.d.ts.map +1 -0
  9. package/packages/core/dist/consultation/dialogue-manager.js +334 -0
  10. package/packages/core/dist/consultation/dialogue-manager.js.map +1 -0
  11. package/packages/core/dist/consultation/dialogue-manager.test.d.ts +19 -0
  12. package/packages/core/dist/consultation/dialogue-manager.test.d.ts.map +1 -0
  13. package/packages/core/dist/consultation/dialogue-manager.test.js +444 -0
  14. package/packages/core/dist/consultation/dialogue-manager.test.js.map +1 -0
  15. package/packages/core/dist/server/api/git.d.ts +13 -1
  16. package/packages/core/dist/server/api/git.d.ts.map +1 -1
  17. package/packages/core/dist/server/api/git.js +53 -34
  18. package/packages/core/dist/server/api/git.js.map +1 -1
  19. package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
  20. package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
  21. package/packages/core/dist/server/otlp-receiver.js +185 -24
  22. package/packages/core/dist/server/otlp-receiver.js.map +1 -1
  23. package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
  24. package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
  25. package/packages/core/dist/server/otlp-receiver.test.js +446 -0
  26. package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
  27. package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
  28. package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
  29. package/packages/core/dist/shared/portrait-resolver.js +27 -0
  30. package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
  31. package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
  32. package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
  33. package/packages/core/dist/shared/skill-search.test.js +2 -2
  34. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
  35. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
  36. package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
  37. package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
  38. package/pennyfarthing-dist/agents/dev.md +1 -1
  39. package/pennyfarthing-dist/agents/reviewer.md +1 -1
  40. package/pennyfarthing-dist/agents/sm-setup.md +1 -1
  41. package/pennyfarthing-dist/agents/sm.md +2 -2
  42. package/pennyfarthing-dist/agents/tea.md +1 -1
  43. package/pennyfarthing-dist/agents/testing-runner.md +2 -1
  44. package/pennyfarthing-dist/commands/pf-chore.md +2 -2
  45. package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
  46. package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
  47. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
  48. package/pennyfarthing-dist/guides/bikerack.md +3 -3
  49. package/pennyfarthing-dist/guides/hooks.md +1 -1
  50. package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
  51. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  52. package/pennyfarthing-dist/scripts/README.md +1 -1
  53. package/pennyfarthing-dist/scripts/core/agent-session.sh +0 -0
  54. package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
  55. package/pennyfarthing-dist/scripts/core/dialogue-manager.sh +322 -0
  56. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +0 -0
  57. package/pennyfarthing-dist/scripts/core/prime.sh +0 -0
  58. package/pennyfarthing-dist/scripts/cyclist/is-cyclist.sh +0 -0
  59. package/pennyfarthing-dist/scripts/git/README.md +24 -14
  60. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
  61. package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
  62. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
  63. package/pennyfarthing-dist/scripts/git/release.sh +0 -0
  64. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
  65. package/pennyfarthing-dist/scripts/health/drift-detection.sh +0 -0
  66. package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
  67. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
  68. package/pennyfarthing-dist/scripts/hooks/context-circuit-breaker.sh +0 -0
  69. package/pennyfarthing-dist/scripts/hooks/context-warning.sh +0 -0
  70. package/pennyfarthing-dist/scripts/hooks/cyclist-pretooluse-hook.sh +0 -0
  71. package/pennyfarthing-dist/scripts/hooks/dispatcher-template.sh +0 -0
  72. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
  73. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +0 -0
  74. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +0 -0
  75. package/pennyfarthing-dist/scripts/hooks/pre-edit-check.sh +0 -0
  76. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +0 -0
  77. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +0 -0
  78. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +0 -0
  79. package/pennyfarthing-dist/scripts/hooks/schema-validation.sh +0 -0
  80. package/pennyfarthing-dist/scripts/hooks/session-start.sh +0 -0
  81. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +0 -0
  82. package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +0 -0
  83. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
  84. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +0 -0
  85. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +0 -0
  86. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +0 -0
  87. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +0 -0
  88. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +0 -0
  89. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +0 -0
  90. package/pennyfarthing-dist/scripts/lib/background-tasks.sh +0 -0
  91. package/pennyfarthing-dist/scripts/lib/checkpoint.sh +0 -0
  92. package/pennyfarthing-dist/scripts/lib/common.sh +0 -0
  93. package/pennyfarthing-dist/scripts/lib/file-lock.sh +0 -0
  94. package/pennyfarthing-dist/scripts/lib/logging.sh +0 -0
  95. package/pennyfarthing-dist/scripts/lib/retry.sh +0 -0
  96. package/pennyfarthing-dist/scripts/maintenance/migrate-theme-schema.mjs +0 -0
  97. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +0 -0
  98. package/pennyfarthing-dist/scripts/misc/add-short-names.sh +0 -0
  99. package/pennyfarthing-dist/scripts/misc/add_short_names.py +0 -0
  100. package/pennyfarthing-dist/scripts/misc/backlog.sh +0 -0
  101. package/pennyfarthing-dist/scripts/misc/check-status.sh +0 -0
  102. package/pennyfarthing-dist/scripts/misc/find-related-work.sh +0 -0
  103. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +0 -0
  104. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +0 -0
  105. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +0 -0
  106. package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +0 -0
  107. package/pennyfarthing-dist/scripts/misc/repo-scan.sh +0 -0
  108. package/pennyfarthing-dist/scripts/misc/repo-utils.sh +0 -0
  109. package/pennyfarthing-dist/scripts/misc/run-ci.sh +0 -0
  110. package/pennyfarthing-dist/scripts/misc/run-timestamp.sh +0 -0
  111. package/pennyfarthing-dist/scripts/misc/session-cleanup.sh +0 -0
  112. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +0 -0
  113. package/pennyfarthing-dist/scripts/misc/statusline.sh +0 -0
  114. package/pennyfarthing-dist/scripts/misc/uninstall.sh +0 -0
  115. package/pennyfarthing-dist/scripts/misc/validate-subagent-frontmatter.sh +0 -0
  116. package/pennyfarthing-dist/scripts/portraits/generate-portraits.sh +0 -0
  117. package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
  118. package/pennyfarthing-dist/scripts/story/create-story.sh +0 -0
  119. package/pennyfarthing-dist/scripts/story/size-story.sh +0 -0
  120. package/pennyfarthing-dist/scripts/story/story-template.sh +0 -0
  121. package/pennyfarthing-dist/scripts/tests/check.test.sh +0 -0
  122. package/pennyfarthing-dist/scripts/tests/dev-story-workflow-import.test.sh +0 -0
  123. package/pennyfarthing-dist/scripts/tests/epics-and-stories-workflow-import.test.sh +0 -0
  124. package/pennyfarthing-dist/scripts/tests/handoff-phase-update.test.sh +0 -0
  125. package/pennyfarthing-dist/scripts/tests/implementation-readiness-workflow-import.test.sh +0 -0
  126. package/pennyfarthing-dist/scripts/tests/migrate-bmad-workflow.test.sh +0 -0
  127. package/pennyfarthing-dist/scripts/tests/prd-workflow-import.test.sh +0 -0
  128. package/pennyfarthing-dist/scripts/tests/project-context-workflow-import.test.sh +0 -0
  129. package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +0 -0
  130. package/pennyfarthing-dist/scripts/tests/test-drift-detection.sh +0 -0
  131. package/pennyfarthing-dist/scripts/tests/test-post-merge-hook.sh +0 -0
  132. package/pennyfarthing-dist/scripts/tests/test-session-checkpoint.sh +0 -0
  133. package/pennyfarthing-dist/scripts/tests/test-solo-command.sh +0 -0
  134. package/pennyfarthing-dist/scripts/tests/ux-design-workflow-import.test.sh +0 -0
  135. package/pennyfarthing-dist/scripts/theme/list-themes.sh +0 -0
  136. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +0 -0
  137. package/pennyfarthing-dist/scripts/workflow/check.py +0 -0
  138. package/pennyfarthing-dist/scripts/workflow/check.sh +0 -0
  139. package/pennyfarthing-dist/scripts/workflow/complete-step.py +0 -0
  140. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +0 -0
  141. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
  142. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +0 -0
  143. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
  144. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
  145. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
  146. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
  147. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
  148. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
  149. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
  150. package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
  151. package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
  152. package/pennyfarthing-dist/skills/pf-story/scripts/create-story.sh +0 -0
  153. package/pennyfarthing-dist/skills/pf-story/scripts/size-story.sh +0 -0
  154. package/pennyfarthing-dist/skills/pf-story/scripts/story-template.sh +0 -0
  155. package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
  156. package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
  157. package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
  158. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
  159. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
  160. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
  161. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
  162. package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
  163. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
  164. package/pennyfarthing_scripts/CLAUDE.md +26 -4
  165. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  166. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  167. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  168. package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
  169. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  170. package/pennyfarthing_scripts/bc/cli.py +3 -5
  171. package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
  172. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  173. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  174. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  175. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  176. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  177. package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
  178. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  179. package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
  180. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  181. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  182. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  183. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  184. package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
  185. package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
  186. package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
  187. package/pennyfarthing_scripts/bikerack/cli.py +10 -11
  188. package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
  189. package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
  190. package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
  191. package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
  192. package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
  193. package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
  194. package/pennyfarthing_scripts/bikerack/tui.py +336 -30
  195. package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
  196. package/pennyfarthing_scripts/cli.py +37 -65
  197. package/pennyfarthing_scripts/consultation/__init__.py +1 -0
  198. package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
  199. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  200. package/pennyfarthing_scripts/consultation/cli.py +149 -0
  201. package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
  202. package/pennyfarthing_scripts/context.py +3 -3
  203. package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
  204. package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
  205. package/pennyfarthing_scripts/git/__init__.py +12 -1
  206. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  207. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  208. package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
  209. package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
  210. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  211. package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
  212. package/pennyfarthing_scripts/git/create_branches.py +3 -4
  213. package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
  214. package/pennyfarthing_scripts/git/repos.py +196 -0
  215. package/pennyfarthing_scripts/git/status_all.py +27 -11
  216. package/pennyfarthing_scripts/git/worktree.py +302 -0
  217. package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
  218. package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
  219. package/pennyfarthing_scripts/git_group/cli.py +143 -40
  220. package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
  221. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  222. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  223. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  224. package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
  225. package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
  226. package/pennyfarthing_scripts/hooks/cyclist-pretooluse-hook.sh +0 -0
  227. package/pennyfarthing_scripts/hooks.py +3 -17
  228. package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
  229. package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
  230. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  231. package/pennyfarthing_scripts/prime/heatmap.py +655 -0
  232. package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
  233. package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
  234. package/pennyfarthing_scripts/session_start_hook.py +1 -1
  235. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  236. package/pennyfarthing_scripts/sprint/loader.py +15 -1
  237. package/pennyfarthing_scripts/sprint/story_finish.py +14 -0
  238. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  239. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
  240. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  241. package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
  242. package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
  243. package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
  244. package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
  245. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  246. package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
  247. package/pennyfarthing_scripts/validate/cli.py +17 -5
  248. package/pennyfarthing_scripts/workflow/__init__.py +40 -0
  249. package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  250. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  251. package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
  252. package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
  253. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
  254. package/pennyfarthing_scripts/workflow/cli.py +1099 -0
  255. package/pennyfarthing_scripts/workflow/helpers.py +241 -0
  256. package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
  257. package/pennyfarthing_scripts/workflow/state.py +112 -0
  258. package/scripts/README.md +41 -0
  259. package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
  260. package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
  261. package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
  262. package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
  263. package/pennyfarthing-dist/skills/pf-workflow/scripts/workflow-status.sh +0 -167
@@ -0,0 +1,196 @@
1
+ """
2
+ Repos.yaml loader — Python replacement for repo-utils.sh (778 lines).
3
+
4
+ Reads .pennyfarthing/repos.yaml and provides structured access to repo
5
+ configuration: paths, types, branches, build/test commands, dependencies.
6
+
7
+ Usage:
8
+ from pennyfarthing_scripts.git.repos import load_repos_config, get_repo_paths
9
+
10
+ config = load_repos_config()
11
+ for name, repo in config.items():
12
+ print(f"{name}: {repo.path} ({repo.default_branch})")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from pennyfarthing_scripts.common.config import get_project_root
24
+
25
+
26
+ @dataclass
27
+ class RepoConfig:
28
+ """Configuration for a single repository."""
29
+
30
+ name: str
31
+ path: str # Relative to project root (e.g., "." or "pennyfarthing")
32
+ repo_type: str # "orchestrator", "framework", "api", "ui", etc.
33
+ default_branch: str # "main" for trunk-based, "develop" for gitflow
34
+ branch_strategy: str # "trunk-based" or "gitflow"
35
+ description: str = ""
36
+ language: str = "unknown"
37
+ test_command: str = ""
38
+ build_command: str = ""
39
+ lint_command: str = ""
40
+ test_filter_flag: str = ""
41
+ dependencies: list[str] = field(default_factory=list)
42
+ owns: list[str] = field(default_factory=list)
43
+ never_edit: list[str] = field(default_factory=list)
44
+ ui_layer: str = "none"
45
+
46
+ @property
47
+ def is_gitflow(self) -> bool:
48
+ return self.branch_strategy == "gitflow"
49
+
50
+ @property
51
+ def upstream_ref(self) -> str:
52
+ """Remote ref to compare against for unpushed commits."""
53
+ return f"origin/{self.default_branch}"
54
+
55
+
56
+ def _parse_repo_entry(name: str, data: dict[str, Any] | None) -> RepoConfig:
57
+ """Parse a single repo entry from repos.yaml."""
58
+ if data is None:
59
+ data = {}
60
+ return RepoConfig(
61
+ name=name,
62
+ path=data.get("path", name),
63
+ repo_type=data.get("type", "unknown"),
64
+ default_branch=data.get("default_branch", "main"),
65
+ branch_strategy=data.get("branch_strategy", "trunk-based"),
66
+ description=data.get("description", ""),
67
+ language=data.get("language", "unknown"),
68
+ test_command=data.get("test_command", ""),
69
+ build_command=data.get("build_command", ""),
70
+ lint_command=data.get("lint_command", ""),
71
+ test_filter_flag=data.get("test_filter_flag", ""),
72
+ dependencies=data.get("dependencies", []) or [],
73
+ owns=data.get("owns", []) or [],
74
+ never_edit=data.get("never_edit", []) or [],
75
+ ui_layer=data.get("ui_layer", "none"),
76
+ )
77
+
78
+
79
+ def load_repos_config(project_root: Path | None = None) -> dict[str, RepoConfig]:
80
+ """Load repos.yaml and return a dict of name -> RepoConfig.
81
+
82
+ Args:
83
+ project_root: Project root directory. Auto-detected if not provided.
84
+
85
+ Returns:
86
+ Ordered dict of repo name -> RepoConfig.
87
+ Empty dict if repos.yaml not found.
88
+ """
89
+ if project_root is None:
90
+ project_root = get_project_root()
91
+
92
+ repos_path = project_root / ".pennyfarthing" / "repos.yaml"
93
+ if not repos_path.exists():
94
+ return {}
95
+
96
+ with open(repos_path) as f:
97
+ config = yaml.safe_load(f)
98
+
99
+ if not config or "repos" not in config:
100
+ return {}
101
+
102
+ repos: dict[str, RepoConfig] = {}
103
+ for name, data in config["repos"].items():
104
+ repos[name] = _parse_repo_entry(name, data)
105
+
106
+ return repos
107
+
108
+
109
+ def get_repo_paths(project_root: Path | None = None) -> list[tuple[str, Path]]:
110
+ """Get list of (name, absolute_path) tuples for all configured repos.
111
+
112
+ Args:
113
+ project_root: Project root directory. Auto-detected if not provided.
114
+
115
+ Returns:
116
+ List of (repo_name, absolute_path) tuples.
117
+ """
118
+ if project_root is None:
119
+ project_root = get_project_root()
120
+
121
+ repos = load_repos_config(project_root)
122
+ result = []
123
+ for name, repo in repos.items():
124
+ abs_path = (project_root / repo.path).resolve()
125
+ if abs_path.exists():
126
+ result.append((name, abs_path))
127
+ return result
128
+
129
+
130
+ def get_default_branch(
131
+ repo_name: str, project_root: Path | None = None
132
+ ) -> str:
133
+ """Get the default branch for a specific repo.
134
+
135
+ Args:
136
+ repo_name: Name of the repo in repos.yaml.
137
+ project_root: Project root directory. Auto-detected if not provided.
138
+
139
+ Returns:
140
+ Default branch name (e.g., "main" or "develop").
141
+ Falls back to "main" if repo not found.
142
+ """
143
+ repos = load_repos_config(project_root)
144
+ if repo_name in repos:
145
+ return repos[repo_name].default_branch
146
+ return "main"
147
+
148
+
149
+ def get_repo_config(
150
+ repo_name: str, project_root: Path | None = None
151
+ ) -> RepoConfig | None:
152
+ """Get the full config for a specific repo.
153
+
154
+ Args:
155
+ repo_name: Name of the repo in repos.yaml.
156
+ project_root: Project root directory. Auto-detected if not provided.
157
+
158
+ Returns:
159
+ RepoConfig or None if not found.
160
+ """
161
+ repos = load_repos_config(project_root)
162
+ return repos.get(repo_name)
163
+
164
+
165
+ def get_build_order(project_root: Path | None = None) -> list[str]:
166
+ """Get repos in build/dependency order.
167
+
168
+ Uses explicit build_order from repos.yaml if present,
169
+ otherwise returns repos in definition order.
170
+
171
+ Args:
172
+ project_root: Project root directory. Auto-detected if not provided.
173
+
174
+ Returns:
175
+ List of repo names in build order.
176
+ """
177
+ if project_root is None:
178
+ project_root = get_project_root()
179
+
180
+ repos_path = project_root / ".pennyfarthing" / "repos.yaml"
181
+ if not repos_path.exists():
182
+ return []
183
+
184
+ with open(repos_path) as f:
185
+ config = yaml.safe_load(f)
186
+
187
+ if not config:
188
+ return []
189
+
190
+ if "build_order" in config:
191
+ return config["build_order"]
192
+
193
+ if "repos" in config:
194
+ return list(config["repos"].keys())
195
+
196
+ return []
@@ -65,12 +65,15 @@ async def _run_git_command(args: list[str], cwd: Path) -> tuple[str, str, int]:
65
65
  )
66
66
 
67
67
 
68
- async def get_repo_status(name: str, path: Path) -> RepoStatus:
68
+ async def get_repo_status(
69
+ name: str, path: Path, upstream_ref: str = "origin/develop"
70
+ ) -> RepoStatus:
69
71
  """Get git status for a single repository.
70
72
 
71
73
  Args:
72
74
  name: Display name for the repo
73
75
  path: Path to the repository
76
+ upstream_ref: Remote ref to compare for unpushed commits (default: origin/develop)
74
77
 
75
78
  Returns:
76
79
  RepoStatus with current branch, changes, and unpushed commits
@@ -124,9 +127,9 @@ async def get_repo_status(name: str, path: Path) -> RepoStatus:
124
127
  status_out, _, _ = await _run_git_command(["status", "--short"], path)
125
128
  changes = [line for line in status_out.split("\n") if line.strip()]
126
129
 
127
- # Get unpushed commits (comparing to origin/develop)
130
+ # Get unpushed commits (comparing to upstream ref)
128
131
  unpushed_out, _, unpushed_rc = await _run_git_command(
129
- ["log", "origin/develop..HEAD", "--oneline"], path
132
+ ["log", f"{upstream_ref}..HEAD", "--oneline"], path
130
133
  )
131
134
  if unpushed_rc == 0 and unpushed_out:
132
135
  unpushed_commits = [
@@ -154,11 +157,13 @@ async def get_repo_status(name: str, path: Path) -> RepoStatus:
154
157
  )
155
158
 
156
159
 
157
- async def get_all_repo_status(repos: Sequence[tuple[str, Path]]) -> list[RepoStatus]:
160
+ async def get_all_repo_status(
161
+ repos: Sequence[tuple[str, Path, str] | tuple[str, Path]],
162
+ ) -> list[RepoStatus]:
158
163
  """Get git status for all repos in parallel using asyncio.gather.
159
164
 
160
165
  Args:
161
- repos: Sequence of (name, path) tuples for each repo
166
+ repos: Sequence of (name, path) or (name, path, upstream_ref) tuples
162
167
 
163
168
  Returns:
164
169
  List of RepoStatus objects in same order as input
@@ -166,7 +171,14 @@ async def get_all_repo_status(repos: Sequence[tuple[str, Path]]) -> list[RepoSta
166
171
  if not repos:
167
172
  return []
168
173
 
169
- tasks = [get_repo_status(name, path) for name, path in repos]
174
+ tasks = []
175
+ for entry in repos:
176
+ if len(entry) == 3:
177
+ name, path, upstream_ref = entry # type: ignore[misc]
178
+ tasks.append(get_repo_status(name, path, upstream_ref))
179
+ else:
180
+ name, path = entry # type: ignore[misc]
181
+ tasks.append(get_repo_status(name, path))
170
182
  results = await asyncio.gather(*tasks, return_exceptions=False)
171
183
  return list(results)
172
184
 
@@ -279,13 +291,17 @@ async def main(brief: bool = False) -> int:
279
291
  Returns:
280
292
  0 if all repos clean, 1 if any have changes/unpushed
281
293
  """
282
- from pennyfarthing_scripts.common.config import get_project_root
294
+ from pennyfarthing_scripts.git.repos import load_repos_config, get_repo_paths
295
+
296
+ repos_with_upstream: list[tuple[str, Path, str]] = []
297
+ repo_paths = get_repo_paths()
298
+ config = load_repos_config()
283
299
 
284
- # For now, just check the current project
285
- project_root = get_project_root()
286
- repos = [("pennyfarthing", project_root)]
300
+ for name, path in repo_paths:
301
+ upstream = config[name].upstream_ref if name in config else "origin/develop"
302
+ repos_with_upstream.append((name, path, upstream))
287
303
 
288
- statuses = await get_all_repo_status(repos)
304
+ statuses = await get_all_repo_status(repos_with_upstream)
289
305
 
290
306
  if brief:
291
307
  print(format_status_brief(statuses))
@@ -0,0 +1,302 @@
1
+ """
2
+ Git worktree management — Python replacement for worktree-manager.sh (498 lines).
3
+
4
+ Manages git worktrees for parallel development across multiple repos.
5
+
6
+ Usage via CLI:
7
+ pf git worktree create <name> <branch> [--repos all|api|ui|name1,name2]
8
+ pf git worktree remove <name>
9
+ pf git worktree list
10
+ pf git worktree status
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from pennyfarthing_scripts.common.config import get_project_root
20
+ from pennyfarthing_scripts.git.repos import load_repos_config
21
+
22
+
23
+ def _git(args: list[str], cwd: Path) -> tuple[str, int]:
24
+ """Run a git command synchronously.
25
+
26
+ Returns:
27
+ Tuple of (stdout, return_code)
28
+ """
29
+ result = subprocess.run(
30
+ ["git", *args],
31
+ cwd=cwd,
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ return result.stdout.strip(), result.returncode
36
+
37
+
38
+ def _get_worktree_root(project_root: Path | None = None) -> Path:
39
+ """Get worktree root directory."""
40
+ if project_root is None:
41
+ project_root = get_project_root()
42
+ env_root = os.environ.get("WORKTREE_ROOT")
43
+ if env_root:
44
+ return Path(env_root)
45
+ return project_root / "worktrees"
46
+
47
+
48
+ def _filter_repos(
49
+ repos: dict[str, object], filter_str: str
50
+ ) -> list[str]:
51
+ """Filter repo names by type or comma-separated list."""
52
+ from pennyfarthing_scripts.git.repos import RepoConfig
53
+
54
+ if filter_str in ("all", "both"):
55
+ return list(repos.keys())
56
+
57
+ # Check if it's a type filter
58
+ if filter_str in ("api", "ui", "adapter", "service"):
59
+ return [
60
+ name
61
+ for name, cfg in repos.items()
62
+ if isinstance(cfg, RepoConfig) and cfg.repo_type == filter_str
63
+ ]
64
+
65
+ # Comma-separated list of names
66
+ return [n.strip() for n in filter_str.split(",") if n.strip()]
67
+
68
+
69
+ def create_worktree(name: str, branch: str, repos_filter: str = "all") -> int:
70
+ """Create worktree(s) for parallel work.
71
+
72
+ Args:
73
+ name: Worktree name (e.g., wt-5-3a)
74
+ branch: Branch name (e.g., feat/5-3a-file-upload)
75
+ repos_filter: Which repos to target (all, api, ui, or comma-separated)
76
+
77
+ Returns:
78
+ 0 on success, 1 on error
79
+ """
80
+ project_root = get_project_root()
81
+ wt_root = _get_worktree_root(project_root)
82
+ wt_path = wt_root / name
83
+
84
+ if wt_path.exists():
85
+ print(f"Error: Worktree '{name}' already exists at {wt_path}")
86
+ return 1
87
+
88
+ repos = load_repos_config(project_root)
89
+ if not repos:
90
+ print("Error: No repositories configured in repos.yaml")
91
+ return 1
92
+
93
+ target_names = _filter_repos(repos, repos_filter)
94
+ if not target_names:
95
+ print(f"No repos match filter: {repos_filter}")
96
+ return 1
97
+
98
+ wt_path.mkdir(parents=True, exist_ok=True)
99
+
100
+ print(f"Creating worktree: {name}")
101
+ print(f" Branch: {branch}")
102
+ print(f" Path: {wt_path}")
103
+ print(f" Repos: {repos_filter}")
104
+ print()
105
+
106
+ created = []
107
+ for repo_name in target_names:
108
+ if repo_name not in repos:
109
+ print(f" SKIP {repo_name} (not in config)")
110
+ continue
111
+
112
+ cfg = repos[repo_name]
113
+ full_path = (project_root / cfg.path).resolve()
114
+
115
+ if not full_path.exists():
116
+ print(f" SKIP {repo_name} (path not found: {full_path})")
117
+ continue
118
+
119
+ print(f"Creating worktree for {repo_name} ({cfg.repo_type})...")
120
+ repo_wt = wt_path / repo_name
121
+
122
+ # Check if branch exists locally or remotely
123
+ _, local_rc = _git(
124
+ ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
125
+ full_path,
126
+ )
127
+ _, remote_rc = _git(
128
+ ["show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
129
+ full_path,
130
+ )
131
+
132
+ if local_rc == 0 or remote_rc == 0:
133
+ _, rc = _git(["worktree", "add", str(repo_wt), branch], full_path)
134
+ else:
135
+ # Create new branch from default branch
136
+ base = cfg.default_branch
137
+ _, base_rc = _git(
138
+ ["show-ref", "--verify", "--quiet", f"refs/heads/{base}"],
139
+ full_path,
140
+ )
141
+ if base_rc != 0:
142
+ base = "main"
143
+ _, rc = _git(
144
+ ["worktree", "add", "-b", branch, str(repo_wt), base],
145
+ full_path,
146
+ )
147
+
148
+ if rc == 0:
149
+ print(f" OK {repo_name}")
150
+ created.append(repo_name)
151
+ else:
152
+ print(f" FAIL {repo_name}")
153
+
154
+ print()
155
+ if created:
156
+ print(f"Worktree '{name}' created successfully!")
157
+ print()
158
+ print("Next steps:")
159
+ for repo_name in created:
160
+ print(f" cd {wt_path / repo_name}")
161
+ else:
162
+ print("No worktrees created.")
163
+ return 1
164
+
165
+ return 0
166
+
167
+
168
+ def remove_worktree(name: str) -> int:
169
+ """Remove worktree and clean up.
170
+
171
+ Args:
172
+ name: Worktree name to remove
173
+
174
+ Returns:
175
+ 0 on success, 1 on error
176
+ """
177
+ project_root = get_project_root()
178
+ wt_root = _get_worktree_root(project_root)
179
+ wt_path = wt_root / name
180
+
181
+ if not wt_path.exists():
182
+ print(f"Error: Worktree '{name}' not found at {wt_path}")
183
+ return 1
184
+
185
+ print(f"Removing worktree: {name}")
186
+
187
+ repos = load_repos_config(project_root)
188
+ for repo_name, cfg in repos.items():
189
+ repo_wt = wt_path / repo_name
190
+ if repo_wt.exists():
191
+ full_path = (project_root / cfg.path).resolve()
192
+ print(f" Removing {repo_name} worktree...")
193
+ _git(["worktree", "remove", str(repo_wt), "--force"], full_path)
194
+
195
+ # Clean up directory
196
+ import shutil
197
+
198
+ if wt_path.exists():
199
+ shutil.rmtree(wt_path)
200
+
201
+ # Prune worktree references
202
+ for repo_name, cfg in repos.items():
203
+ full_path = (project_root / cfg.path).resolve()
204
+ if full_path.exists():
205
+ _git(["worktree", "prune"], full_path)
206
+
207
+ print()
208
+ print("Note: Session file (if any) should be archived via /sm finish")
209
+ print()
210
+ print(f"Worktree '{name}' removed successfully!")
211
+ return 0
212
+
213
+
214
+ def list_worktrees() -> int:
215
+ """List all active worktrees.
216
+
217
+ Returns:
218
+ 0 always
219
+ """
220
+ project_root = get_project_root()
221
+ wt_root = _get_worktree_root(project_root)
222
+ repos = load_repos_config(project_root)
223
+
224
+ print("=== Active Worktrees ===")
225
+ print()
226
+
227
+ for repo_name, cfg in repos.items():
228
+ full_path = (project_root / cfg.path).resolve()
229
+ if full_path.exists():
230
+ print(f"{repo_name} ({cfg.repo_type}):")
231
+ output, _ = _git(["worktree", "list"], full_path)
232
+ if output:
233
+ print(output)
234
+ print()
235
+
236
+ print("Worktree Directory:")
237
+ if wt_root.exists() and any(wt_root.iterdir()):
238
+ for item in sorted(wt_root.iterdir()):
239
+ if item.is_dir():
240
+ print(f" {item.name}/")
241
+ else:
242
+ print(" (empty)")
243
+
244
+ return 0
245
+
246
+
247
+ def show_worktree_status() -> int:
248
+ """Show detailed worktree status.
249
+
250
+ Returns:
251
+ 0 always
252
+ """
253
+ project_root = get_project_root()
254
+ wt_root = _get_worktree_root(project_root)
255
+ repos = load_repos_config(project_root)
256
+
257
+ print("=== Worktree Status ===")
258
+ print()
259
+
260
+ if not wt_root.exists() or not any(wt_root.iterdir()):
261
+ print("No active worktrees.")
262
+ print()
263
+ print("Create one with:")
264
+ print(" pf git worktree create <name> <branch>")
265
+ return 0
266
+
267
+ for wt_dir in sorted(wt_root.iterdir()):
268
+ if not wt_dir.is_dir():
269
+ continue
270
+
271
+ wt_name = wt_dir.name
272
+ print(f"{wt_name}")
273
+ print(f" Path: {wt_dir}")
274
+
275
+ for repo_name, cfg in repos.items():
276
+ repo_wt = wt_dir / repo_name
277
+ if repo_wt.exists():
278
+ branch, _ = _git(["branch", "--show-current"], repo_wt)
279
+ status_out, _ = _git(["status", "--short"], repo_wt)
280
+ count = len([l for l in status_out.split("\n") if l.strip()]) if status_out else 0
281
+ print(f" {repo_name} ({cfg.repo_type}): {branch} ({count} uncommitted)")
282
+
283
+ # Check for session files referencing this worktree
284
+ session_dir = project_root / ".session"
285
+ found_session = None
286
+ if session_dir.exists():
287
+ for sf in session_dir.glob("*-session.md"):
288
+ try:
289
+ content = sf.read_text()
290
+ if f"worktree: {wt_name}" in content:
291
+ found_session = sf.name
292
+ break
293
+ except OSError:
294
+ pass
295
+
296
+ if found_session:
297
+ print(f" Session: .session/{found_session}")
298
+ else:
299
+ print(" Session: (no session file references this worktree)")
300
+ print()
301
+
302
+ return 0