@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,417 @@
1
+ """Dialogue file management for tandem agent consultation.
2
+
3
+ Persistence layer for consultation exchanges between tandem agents.
4
+ Port of packages/core/src/consultation/dialogue-manager.ts to Python.
5
+
6
+ All pure functions use the same markdown format defined in ADR-0012.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shutil
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ Outcome = Literal["applied", "deferred", "rejected"]
18
+
19
+ SUMMARY_MARKER = "## Summary"
20
+ EXCHANGE_RE = re.compile(r"^## Exchange (\d+)")
21
+ OUTCOME_RE = re.compile(r"^\*\*Outcome:\*\*\s+(.+)")
22
+ DIRECTION_RE = re.compile(r"^\*\*\[(\d{2}:\d{2})\]\s+(\S+)\s+→\s+(\S+)\*\*")
23
+ PARTNER_RESP_RE = re.compile(r"^\*\*\[(\d{2}:\d{2})\]\s+(\S+):\*\*")
24
+ CONFIDENCE_RE = re.compile(r"^\*\*Confidence:\*\*\s+(\S+)")
25
+
26
+
27
+ @dataclass
28
+ class DialogueHeader:
29
+ story_id: str
30
+ workflow: str
31
+ leader: str
32
+ partner: str
33
+ leader_character: str | None = None
34
+ partner_character: str | None = None
35
+ started_at: str = ""
36
+
37
+
38
+ @dataclass
39
+ class DialogueExchange:
40
+ number: int
41
+ timestamp: str # HH:MM
42
+ leader: str
43
+ partner: str
44
+ question: str
45
+ recommendation: str
46
+ confidence: str
47
+ outcome: Outcome | None = None
48
+ outcome_note: str | None = None
49
+
50
+
51
+ @dataclass
52
+ class DialogueResult:
53
+ success: bool
54
+ data: dict | None = None
55
+ error: str | None = None
56
+
57
+
58
+ # =============================================================================
59
+ # Pure Functions
60
+ # =============================================================================
61
+
62
+
63
+ def create_dialogue_content(header: DialogueHeader) -> str:
64
+ """Create initial dialogue file content with header and empty summary."""
65
+ leader_label = (
66
+ f"{header.leader} ({header.leader_character})"
67
+ if header.leader_character
68
+ else header.leader
69
+ )
70
+ partner_label = (
71
+ f"{header.partner} ({header.partner_character})"
72
+ if header.partner_character
73
+ else header.partner
74
+ )
75
+
76
+ return (
77
+ f"# Tandem Dialogue: {header.story_id}\n"
78
+ f"\n"
79
+ f"**Workflow:** {header.workflow}\n"
80
+ f"**Leader:** {leader_label} | **Partner:** {partner_label}\n"
81
+ f"**Started:** {header.started_at}\n"
82
+ f"\n"
83
+ f"---\n"
84
+ f"\n"
85
+ f"{SUMMARY_MARKER}\n"
86
+ f"- **Total exchanges:** 0\n"
87
+ f"- **Key decisions:** None\n"
88
+ f"- **Time in tandem:** 0m\n"
89
+ )
90
+
91
+
92
+ def format_exchange(exchange: DialogueExchange) -> str:
93
+ """Format a single exchange as markdown block."""
94
+ if exchange.outcome:
95
+ outcome_text = f"**Outcome:** {exchange.outcome}"
96
+ if exchange.outcome_note:
97
+ outcome_text += f" - {exchange.outcome_note}"
98
+ else:
99
+ outcome_text = "**Outcome:** _pending_"
100
+
101
+ return (
102
+ f"## Exchange {exchange.number}\n"
103
+ f"**[{exchange.timestamp}] {exchange.leader} \u2192 {exchange.partner}**\n"
104
+ f"\n"
105
+ f"> {exchange.question}\n"
106
+ f"\n"
107
+ f"**[{exchange.timestamp}] {exchange.partner}:**\n"
108
+ f"\n"
109
+ f"{exchange.recommendation}\n"
110
+ f"\n"
111
+ f"**Confidence:** {exchange.confidence}\n"
112
+ f"\n"
113
+ f"{outcome_text}\n"
114
+ f"\n"
115
+ f"---\n"
116
+ )
117
+
118
+
119
+ def parse_dialogue_exchanges(content: str) -> list[DialogueExchange]:
120
+ """Parse exchanges from dialogue file content."""
121
+ exchanges: list[DialogueExchange] = []
122
+ lines = content.split("\n")
123
+
124
+ current: dict | None = None
125
+ in_question = False
126
+ in_recommendation = False
127
+ question_lines: list[str] = []
128
+ rec_lines: list[str] = []
129
+
130
+ for line in lines:
131
+ # New exchange starts
132
+ m = EXCHANGE_RE.match(line)
133
+ if m:
134
+ if current is not None and "number" in current:
135
+ current["question"] = "\n".join(question_lines)
136
+ current["recommendation"] = "\n".join(rec_lines)
137
+ exchanges.append(DialogueExchange(**current))
138
+ current = {"number": int(m.group(1))}
139
+ question_lines = []
140
+ rec_lines = []
141
+ in_question = False
142
+ in_recommendation = False
143
+ continue
144
+
145
+ if current is None:
146
+ continue
147
+
148
+ # Leader → Partner direction line
149
+ dm = DIRECTION_RE.match(line)
150
+ if dm:
151
+ current["timestamp"] = dm.group(1)
152
+ current["leader"] = dm.group(2)
153
+ current["partner"] = dm.group(3)
154
+ in_question = True
155
+ in_recommendation = False
156
+ continue
157
+
158
+ # Partner response line
159
+ pm = PARTNER_RESP_RE.match(line)
160
+ if pm:
161
+ in_question = False
162
+ in_recommendation = True
163
+ continue
164
+
165
+ # Confidence line
166
+ cm = CONFIDENCE_RE.match(line)
167
+ if cm:
168
+ current["confidence"] = cm.group(1)
169
+ in_recommendation = False
170
+ continue
171
+
172
+ # Outcome line
173
+ om = OUTCOME_RE.match(line)
174
+ if om:
175
+ outcome_raw = om.group(1).strip()
176
+ if outcome_raw != "_pending_":
177
+ dash_idx = outcome_raw.find(" - ")
178
+ if dash_idx >= 0:
179
+ current["outcome"] = outcome_raw[:dash_idx].strip()
180
+ current["outcome_note"] = outcome_raw[dash_idx + 3 :].strip()
181
+ else:
182
+ current["outcome"] = outcome_raw
183
+ in_recommendation = False
184
+ continue
185
+
186
+ # Collect question text (blockquote lines)
187
+ if in_question:
188
+ stripped = line[2:] if line.startswith("> ") else line
189
+ if stripped.strip():
190
+ question_lines.append(stripped)
191
+ continue
192
+
193
+ # Collect recommendation text
194
+ if in_recommendation:
195
+ if line.strip():
196
+ rec_lines.append(line)
197
+ continue
198
+
199
+ # Push last exchange
200
+ if current is not None and "number" in current:
201
+ current["question"] = "\n".join(question_lines)
202
+ current["recommendation"] = "\n".join(rec_lines)
203
+ exchanges.append(DialogueExchange(**current))
204
+
205
+ return exchanges
206
+
207
+
208
+ def generate_summary(exchanges: list[DialogueExchange], started_at: str) -> str:
209
+ """Generate summary markdown section from exchanges."""
210
+ total = len(exchanges)
211
+
212
+ # Key decisions from applied outcomes
213
+ applied = [e for e in exchanges if e.outcome == "applied" and e.outcome_note]
214
+ if applied:
215
+ decisions_text = "\n".join(f" - {e.outcome_note}" for e in applied)
216
+ else:
217
+ decisions_text = "None"
218
+
219
+ # Time in tandem: span between first and last exchange timestamps
220
+ duration = "0m"
221
+ if exchanges:
222
+ first = _parse_time(exchanges[0].timestamp)
223
+ last = _parse_time(exchanges[-1].timestamp)
224
+ if first is not None and last is not None:
225
+ mins = last - first
226
+ duration = f"{mins}m" if mins > 0 else "0m"
227
+
228
+ return (
229
+ f"{SUMMARY_MARKER}\n"
230
+ f"- **Total exchanges:** {total}\n"
231
+ f"- **Key decisions:**\n"
232
+ f"{decisions_text}\n"
233
+ f"- **Time in tandem:** {duration}\n"
234
+ )
235
+
236
+
237
+ # =============================================================================
238
+ # File Operations
239
+ # =============================================================================
240
+
241
+
242
+ def append_exchange_to_file(
243
+ dialogue_path: Path,
244
+ exchange: DialogueExchange,
245
+ header: DialogueHeader | None = None,
246
+ ) -> DialogueResult:
247
+ """Append an exchange to a dialogue file. Creates the file if missing."""
248
+ try:
249
+ if not dialogue_path.exists():
250
+ if not header:
251
+ return DialogueResult(
252
+ success=False, error="Header required for new dialogue file"
253
+ )
254
+ initial = create_dialogue_content(header)
255
+ dialogue_path.parent.mkdir(parents=True, exist_ok=True)
256
+ dialogue_path.write_text(initial, encoding="utf-8")
257
+
258
+ content = dialogue_path.read_text(encoding="utf-8")
259
+ formatted = format_exchange(exchange)
260
+
261
+ # Insert exchange before summary section
262
+ summary_idx = content.find(SUMMARY_MARKER)
263
+ if summary_idx < 0:
264
+ dialogue_path.write_text(
265
+ content + "\n" + formatted, encoding="utf-8"
266
+ )
267
+ else:
268
+ before = content[:summary_idx]
269
+ after = content[summary_idx:]
270
+ dialogue_path.write_text(
271
+ before + formatted + "\n" + after, encoding="utf-8"
272
+ )
273
+
274
+ return DialogueResult(
275
+ success=True, data={"exchangeNumber": exchange.number}
276
+ )
277
+ except Exception as err:
278
+ return DialogueResult(
279
+ success=False, error=f"Failed to append exchange: {err}"
280
+ )
281
+
282
+
283
+ def update_outcome_in_file(
284
+ dialogue_path: Path,
285
+ exchange_num: int,
286
+ outcome: Outcome,
287
+ note: str | None = None,
288
+ ) -> DialogueResult:
289
+ """Update the outcome of a specific exchange in the dialogue file."""
290
+ try:
291
+ if not dialogue_path.exists():
292
+ return DialogueResult(
293
+ success=False,
294
+ error=f"Dialogue file not found: {dialogue_path}",
295
+ )
296
+
297
+ content = dialogue_path.read_text(encoding="utf-8")
298
+ lines = content.split("\n")
299
+
300
+ in_target = False
301
+ found = False
302
+
303
+ for i, line in enumerate(lines):
304
+ em = EXCHANGE_RE.match(line)
305
+ if em:
306
+ in_target = int(em.group(1)) == exchange_num
307
+
308
+ if in_target and OUTCOME_RE.match(line):
309
+ outcome_text = f"**Outcome:** {outcome}"
310
+ if note:
311
+ outcome_text += f" - {note}"
312
+ lines[i] = outcome_text
313
+ found = True
314
+ break
315
+
316
+ if not found:
317
+ return DialogueResult(
318
+ success=False,
319
+ error=f"Exchange {exchange_num} not found in dialogue file",
320
+ )
321
+
322
+ dialogue_path.write_text("\n".join(lines), encoding="utf-8")
323
+ return DialogueResult(
324
+ success=True, data={"exchangeNum": exchange_num, "outcome": outcome}
325
+ )
326
+ except Exception as err:
327
+ return DialogueResult(
328
+ success=False, error=f"Failed to update outcome: {err}"
329
+ )
330
+
331
+
332
+ def refresh_summary(dialogue_path: Path) -> DialogueResult:
333
+ """Regenerate the summary section in an existing dialogue file."""
334
+ try:
335
+ if not dialogue_path.exists():
336
+ return DialogueResult(
337
+ success=False,
338
+ error=f"Dialogue file not found: {dialogue_path}",
339
+ )
340
+
341
+ content = dialogue_path.read_text(encoding="utf-8")
342
+ exchanges = parse_dialogue_exchanges(content)
343
+
344
+ # Extract startedAt from header
345
+ started_match = re.search(r"\*\*Started:\*\*\s+(.+)", content)
346
+ started_at = (
347
+ started_match.group(1).strip() if started_match else ""
348
+ )
349
+
350
+ new_summary = generate_summary(exchanges, started_at)
351
+
352
+ # Replace existing summary section
353
+ summary_idx = content.find(SUMMARY_MARKER)
354
+ if summary_idx < 0:
355
+ dialogue_path.write_text(
356
+ content + "\n" + new_summary, encoding="utf-8"
357
+ )
358
+ else:
359
+ before = content[:summary_idx]
360
+ dialogue_path.write_text(before + new_summary, encoding="utf-8")
361
+
362
+ return DialogueResult(
363
+ success=True, data={"totalExchanges": len(exchanges)}
364
+ )
365
+ except Exception as err:
366
+ return DialogueResult(
367
+ success=False, error=f"Failed to refresh summary: {err}"
368
+ )
369
+
370
+
371
+ def archive_dialogue(
372
+ dialogue_path: Path,
373
+ archive_dir: Path,
374
+ jira_key: str | None = None,
375
+ story_id: str | None = None,
376
+ ) -> DialogueResult:
377
+ """Copy dialogue file to archive directory."""
378
+ try:
379
+ if not dialogue_path.exists():
380
+ return DialogueResult(
381
+ success=False,
382
+ error=f"Dialogue file not found: {dialogue_path}",
383
+ )
384
+
385
+ archive_dir.mkdir(parents=True, exist_ok=True)
386
+
387
+ prefix = jira_key or story_id or "unknown"
388
+ archive_name = f"{prefix}-dialogue.md"
389
+ archive_path = archive_dir / archive_name
390
+
391
+ shutil.copy2(dialogue_path, archive_path)
392
+
393
+ return DialogueResult(
394
+ success=True, data={"archivePath": str(archive_path)}
395
+ )
396
+ except Exception as err:
397
+ return DialogueResult(
398
+ success=False, error=f"Failed to archive dialogue: {err}"
399
+ )
400
+
401
+
402
+ # =============================================================================
403
+ # Internal Helpers
404
+ # =============================================================================
405
+
406
+
407
+ def _parse_time(timestamp: str) -> int | None:
408
+ """Parse HH:MM timestamp to minutes since midnight."""
409
+ parts = timestamp.split(":")
410
+ if len(parts) != 2:
411
+ return None
412
+ try:
413
+ hours = int(parts[0])
414
+ minutes = int(parts[1])
415
+ except ValueError:
416
+ return None
417
+ return hours * 60 + minutes
@@ -269,7 +269,7 @@ def detect_cyclist(project_dir: str | None = None) -> bool:
269
269
 
270
270
  Checks:
271
271
  1. CYCLIST env var set to '1' (Electron mode - definitive)
272
- 2. .cyclist-port file exists AND port is responding (Web mode)
272
+ 2. .wheelhub-port file exists AND port is responding (Web mode)
273
273
  """
274
274
  # Env var is definitive - set by Cyclist when it spawns Claude
275
275
  if os.environ.get("CYCLIST") == "1":
@@ -284,8 +284,8 @@ def detect_cyclist(project_dir: str | None = None) -> bool:
284
284
  )
285
285
 
286
286
  port_files = [
287
- Path(project_dir) / "packages" / "cyclist" / ".cyclist-port",
288
- Path(os.getcwd()) / ".cyclist-port",
287
+ Path(project_dir) / "packages" / "cyclist" / ".wheelhub-port",
288
+ Path(os.getcwd()) / ".wheelhub-port",
289
289
  ]
290
290
 
291
291
  for port_file in port_files:
@@ -4,14 +4,22 @@ Git utilities for Pennyfarthing.
4
4
  Story: MSSCI-12402 - Port git utility scripts to Python
5
5
 
6
6
  This package provides async git operations for multi-repo management:
7
+ - repos: Repository configuration from repos.yaml
7
8
  - status_all: Check git status across all repos in parallel
8
9
  - create_branches: Create feature branches across repos in parallel
10
+ - worktree: Git worktree management for parallel development
11
+ - hooks_installer: Git hooks installation with .d/ dispatcher pattern
9
12
  """
10
13
 
11
14
  from pennyfarthing_scripts.git.create_branches import (
12
15
  BranchResult,
13
16
  create_feature_branches,
14
17
  )
18
+ from pennyfarthing_scripts.git.repos import (
19
+ RepoConfig,
20
+ get_repo_paths,
21
+ load_repos_config,
22
+ )
15
23
  from pennyfarthing_scripts.git.status_all import (
16
24
  RepoStatus,
17
25
  format_status_brief,
@@ -20,10 +28,13 @@ from pennyfarthing_scripts.git.status_all import (
20
28
  )
21
29
 
22
30
  __all__ = [
31
+ "RepoConfig",
23
32
  "RepoStatus",
33
+ "BranchResult",
24
34
  "get_all_repo_status",
25
35
  "format_status_brief",
26
36
  "format_status_full",
27
- "BranchResult",
28
37
  "create_feature_branches",
38
+ "load_repos_config",
39
+ "get_repo_paths",
29
40
  ]
@@ -395,7 +395,7 @@ async def main(branch_name: str, repos_filter: RepoFilter = "all") -> int:
395
395
  Returns:
396
396
  0 if all repos succeeded, 1 if any had errors
397
397
  """
398
- from pennyfarthing_scripts.common.config import get_project_root
398
+ from pennyfarthing_scripts.git.repos import get_repo_paths
399
399
 
400
400
  # Detect worktree
401
401
  is_worktree, worktree_name, base_path = detect_worktree()
@@ -405,9 +405,8 @@ async def main(branch_name: str, repos_filter: RepoFilter = "all") -> int:
405
405
  else:
406
406
  print("📂 Using main checkout")
407
407
 
408
- # For now, just use the current project
409
- project_root = get_project_root()
410
- repos = [("pennyfarthing", project_root)]
408
+ # Load repos from configuration
409
+ repos = get_repo_paths()
411
410
 
412
411
  # Apply filter
413
412
  filtered_repos = filter_repos(repos, repos_filter)
@@ -0,0 +1,152 @@
1
+ """
2
+ Git hooks installer — Python replacement for install-git-hooks.sh (145 lines).
3
+
4
+ Creates .d/ dispatcher directories, symlinks pennyfarthing hooks,
5
+ and migrates existing user hooks.
6
+
7
+ Usage via CLI:
8
+ pf git install-hooks
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from pennyfarthing_scripts.common.config import get_project_root
17
+
18
+ DISPATCHER_MARKER = "pennyfarthing-dispatcher"
19
+ PF_PREFIX = "10"
20
+ MIGRATED_PREFIX = "50"
21
+
22
+ # Hook source file → git hook name
23
+ HOOKS = [
24
+ ("pre-commit.sh", "pre-commit"),
25
+ ("pre-push.sh", "pre-push"),
26
+ ("post-merge.sh", "post-merge"),
27
+ ]
28
+
29
+
30
+ def _generate_dispatcher(template_path: Path, hook_name: str) -> str:
31
+ """Generate a dispatcher script from the template."""
32
+ template = template_path.read_text()
33
+ return template.replace("__HOOK_NAME__", hook_name)
34
+
35
+
36
+ def install_git_hooks(project_root: Path | None = None) -> int:
37
+ """Install git hooks with .d/ dispatcher pattern.
38
+
39
+ Creates .d/ directories for each hook, installs dispatcher scripts,
40
+ and symlinks pennyfarthing hooks into the .d/ directories.
41
+ Existing non-pennyfarthing hooks are migrated into .d/.
42
+
43
+ Args:
44
+ project_root: Project root. Auto-detected if not provided.
45
+
46
+ Returns:
47
+ 0 on success, 1 on error
48
+ """
49
+ if project_root is None:
50
+ project_root = get_project_root()
51
+
52
+ pf_dist = project_root / "pennyfarthing-dist"
53
+ if not pf_dist.is_dir():
54
+ print("Error: This script requires pennyfarthing-dist/ at the project root")
55
+ print(" End-user projects should use: pennyfarthing init")
56
+ return 1
57
+
58
+ git_dir = project_root / ".git"
59
+ if not git_dir.is_dir():
60
+ print("Error: Not a git repository")
61
+ return 1
62
+
63
+ hooks_source = pf_dist / "scripts" / "hooks"
64
+ hooks_dest = git_dir / "hooks"
65
+ hooks_dest.mkdir(exist_ok=True)
66
+
67
+ dispatcher_template = hooks_source / "dispatcher-template.sh"
68
+ if not dispatcher_template.is_file():
69
+ print(f"Error: dispatcher-template.sh not found at {dispatcher_template}")
70
+ return 1
71
+
72
+ print("Installing git hooks with .d/ dispatcher pattern...")
73
+ print(f" Source: pennyfarthing-dist/scripts/hooks/")
74
+ print(f" Dest: .git/hooks/")
75
+ print()
76
+
77
+ for source_file, dest_name in HOOKS:
78
+ source_path = hooks_source / source_file
79
+ dest_path = hooks_dest / dest_name
80
+ d_dir = hooks_dest / f"{dest_name}.d"
81
+ pf_hook_name = f"{PF_PREFIX}-pennyfarthing-{dest_name}.sh"
82
+ pf_hook_path = d_dir / pf_hook_name
83
+
84
+ if not source_path.is_file():
85
+ print(f" SKIP {dest_name} (source not found)")
86
+ continue
87
+
88
+ # Create .d/ directory
89
+ d_dir.mkdir(exist_ok=True)
90
+
91
+ # Handle existing hook at dest path
92
+ if dest_path.exists():
93
+ if dest_path.is_file():
94
+ content = dest_path.read_text()
95
+ if DISPATCHER_MARKER in content:
96
+ print(f" OK {dest_name} dispatcher (already installed)")
97
+ elif dest_path.is_symlink():
98
+ # Old-style symlink — replace with dispatcher
99
+ dest_path.unlink()
100
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
101
+ dest_path.chmod(0o755)
102
+ print(f" UPD {dest_name} -> dispatcher")
103
+ elif "pennyfarthing" in content:
104
+ # Old pennyfarthing single-file hook — replace
105
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
106
+ dest_path.chmod(0o755)
107
+ print(f" UPD {dest_name} -> dispatcher (was single-file pf hook)")
108
+ else:
109
+ # Non-pennyfarthing hook — migrate into .d/
110
+ migrated_name = f"{MIGRATED_PREFIX}-migrated-{dest_name}.sh"
111
+ migrated_path = d_dir / migrated_name
112
+ if not migrated_path.exists():
113
+ dest_path.rename(migrated_path)
114
+ migrated_path.chmod(0o755)
115
+ print(f" MIG {dest_name} -> {dest_name}.d/{migrated_name}")
116
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
117
+ dest_path.chmod(0o755)
118
+ print(f" NEW {dest_name} dispatcher")
119
+ elif dest_path.is_symlink():
120
+ dest_path.unlink()
121
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
122
+ dest_path.chmod(0o755)
123
+ print(f" UPD {dest_name} -> dispatcher")
124
+ else:
125
+ # No existing hook — install fresh dispatcher
126
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
127
+ dest_path.chmod(0o755)
128
+ print(f" NEW {dest_name} dispatcher")
129
+
130
+ # Symlink pennyfarthing hook into .d/
131
+ # Relative path from .git/hooks/{hook}.d/ to pennyfarthing-dist/scripts/hooks/
132
+ relative_path = Path("../../../pennyfarthing-dist/scripts/hooks") / source_file
133
+
134
+ if pf_hook_path.is_symlink():
135
+ current_target = pf_hook_path.readlink()
136
+ if current_target == relative_path:
137
+ print(f" OK {dest_name}.d/{pf_hook_name} (already linked)")
138
+ else:
139
+ pf_hook_path.unlink()
140
+ pf_hook_path.symlink_to(relative_path)
141
+ print(f" UPD {dest_name}.d/{pf_hook_name} -> {relative_path}")
142
+ elif pf_hook_path.is_file():
143
+ pf_hook_path.unlink()
144
+ pf_hook_path.symlink_to(relative_path)
145
+ print(f" UPD {dest_name}.d/{pf_hook_name} -> {relative_path} (was copy)")
146
+ else:
147
+ pf_hook_path.symlink_to(relative_path)
148
+ print(f" NEW {dest_name}.d/{pf_hook_name} -> {relative_path}")
149
+
150
+ print()
151
+ print("Done. Verify with: ls -la .git/hooks/*.d/")
152
+ return 0