@pennyfarthing/core 11.1.1 → 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 (160) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
  4. package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
  5. package/packages/core/dist/server/otlp-receiver.js +185 -24
  6. package/packages/core/dist/server/otlp-receiver.js.map +1 -1
  7. package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
  8. package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
  9. package/packages/core/dist/server/otlp-receiver.test.js +446 -0
  10. package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
  11. package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
  12. package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
  13. package/packages/core/dist/shared/portrait-resolver.js +27 -0
  14. package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
  15. package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
  16. package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
  17. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
  18. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
  19. package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
  20. package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
  21. package/pennyfarthing-dist/agents/dev.md +1 -1
  22. package/pennyfarthing-dist/agents/reviewer.md +1 -1
  23. package/pennyfarthing-dist/agents/sm-setup.md +1 -1
  24. package/pennyfarthing-dist/agents/sm.md +2 -2
  25. package/pennyfarthing-dist/agents/tea.md +1 -1
  26. package/pennyfarthing-dist/agents/testing-runner.md +2 -1
  27. package/pennyfarthing-dist/commands/pf-chore.md +2 -2
  28. package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
  29. package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
  30. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
  31. package/pennyfarthing-dist/guides/bikerack.md +3 -3
  32. package/pennyfarthing-dist/guides/hooks.md +1 -1
  33. package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
  34. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  35. package/pennyfarthing-dist/scripts/README.md +1 -1
  36. package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
  37. package/pennyfarthing-dist/scripts/git/README.md +24 -14
  38. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
  39. package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
  40. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
  41. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
  42. package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
  43. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
  44. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
  45. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
  46. package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
  55. package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
  56. package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
  57. package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
  58. package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
  59. package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
  60. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
  61. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
  62. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
  63. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
  64. package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
  65. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
  66. package/pennyfarthing_scripts/CLAUDE.md +26 -4
  67. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  68. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  69. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/bc/cli.py +3 -5
  73. package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
  74. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  75. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  76. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
  80. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  81. package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
  82. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  83. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
  87. package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
  88. package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
  89. package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
  90. package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
  91. package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
  92. package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
  93. package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
  94. package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
  95. package/pennyfarthing_scripts/bikerack/tui.py +336 -30
  96. package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
  97. package/pennyfarthing_scripts/cli.py +37 -65
  98. package/pennyfarthing_scripts/consultation/__init__.py +1 -0
  99. package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/consultation/cli.py +149 -0
  102. package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
  103. package/pennyfarthing_scripts/context.py +3 -3
  104. package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/git/__init__.py +12 -1
  107. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
  110. package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  112. package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
  113. package/pennyfarthing_scripts/git/create_branches.py +3 -4
  114. package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
  115. package/pennyfarthing_scripts/git/repos.py +196 -0
  116. package/pennyfarthing_scripts/git/status_all.py +27 -11
  117. package/pennyfarthing_scripts/git/worktree.py +302 -0
  118. package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/git_group/cli.py +143 -40
  121. package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
  122. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  123. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  124. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  125. package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
  126. package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
  127. package/pennyfarthing_scripts/hooks.py +3 -17
  128. package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
  129. package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/heatmap.py +655 -0
  131. package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/session_start_hook.py +1 -1
  134. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/sprint/loader.py +15 -1
  136. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  137. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
  138. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
  140. package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
  141. package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
  142. package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
  143. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
  145. package/pennyfarthing_scripts/validate/cli.py +17 -5
  146. package/pennyfarthing_scripts/workflow/__init__.py +40 -0
  147. package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/workflow/cli.py +1099 -0
  153. package/pennyfarthing_scripts/workflow/helpers.py +241 -0
  154. package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
  155. package/pennyfarthing_scripts/workflow/state.py +112 -0
  156. package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
  157. package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
  158. package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
  159. package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
  160. package/pennyfarthing-dist/skills/pf-workflow/scripts/workflow-status.sh +0 -167
@@ -0,0 +1,149 @@
1
+ """Click CLI for consultation dialogue management.
2
+
3
+ Provides `pf consultation` group with subcommands matching
4
+ the bash wrapper dialogue-manager.sh interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+
15
+ @click.group()
16
+ def consultation():
17
+ """Tandem consultation dialogue management.
18
+
19
+ \b
20
+ Subcommands:
21
+ init - Create dialogue file
22
+ append - Append exchange
23
+ outcome - Update outcome
24
+ summarize - Refresh summary
25
+ archive - Archive dialogue file
26
+ """
27
+ pass
28
+
29
+
30
+ @consultation.command()
31
+ @click.argument("story_id")
32
+ @click.argument("workflow")
33
+ @click.argument("leader")
34
+ @click.argument("partner")
35
+ def init(story_id: str, workflow: str, leader: str, partner: str) -> None:
36
+ """Create a new dialogue file."""
37
+ from pennyfarthing_scripts.consultation.dialogue_manager import (
38
+ DialogueHeader,
39
+ create_dialogue_content,
40
+ )
41
+
42
+ header = DialogueHeader(
43
+ story_id=story_id,
44
+ workflow=workflow,
45
+ leader=leader,
46
+ partner=partner,
47
+ started_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
48
+ )
49
+
50
+ content = create_dialogue_content(header)
51
+ session_dir = Path(".session")
52
+ session_dir.mkdir(parents=True, exist_ok=True)
53
+ dialogue_path = session_dir / f"{story_id}-dialogue.md"
54
+ dialogue_path.write_text(content, encoding="utf-8")
55
+
56
+ click.echo(f"Created {dialogue_path}")
57
+
58
+
59
+ @consultation.command()
60
+ @click.argument("story_id")
61
+ @click.argument("question")
62
+ @click.argument("recommendation")
63
+ @click.argument("confidence")
64
+ def append(story_id: str, question: str, recommendation: str, confidence: str) -> None:
65
+ """Append an exchange to a dialogue file."""
66
+ from pennyfarthing_scripts.consultation.dialogue_manager import (
67
+ DialogueExchange,
68
+ append_exchange_to_file,
69
+ parse_dialogue_exchanges,
70
+ )
71
+
72
+ dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
73
+ if not dialogue_path.exists():
74
+ click.echo(f"Dialogue file not found: {dialogue_path}", err=True)
75
+ raise SystemExit(1)
76
+
77
+ content = dialogue_path.read_text(encoding="utf-8")
78
+ existing = parse_dialogue_exchanges(content)
79
+ next_num = len(existing) + 1
80
+
81
+ exchange = DialogueExchange(
82
+ number=next_num,
83
+ timestamp=datetime.now(timezone.utc).strftime("%H:%M"),
84
+ leader="",
85
+ partner="",
86
+ question=question,
87
+ recommendation=recommendation,
88
+ confidence=confidence,
89
+ )
90
+
91
+ result = append_exchange_to_file(dialogue_path, exchange)
92
+ if result.success:
93
+ click.echo(f"Appended exchange {next_num}")
94
+ else:
95
+ click.echo(f"Error: {result.error}", err=True)
96
+ raise SystemExit(1)
97
+
98
+
99
+ @consultation.command()
100
+ @click.argument("story_id")
101
+ @click.argument("exchange_num", type=int)
102
+ @click.argument("outcome_value")
103
+ @click.option("--note", default=None, help="Outcome note")
104
+ def outcome(story_id: str, exchange_num: int, outcome_value: str, note: str | None) -> None:
105
+ """Update outcome of an exchange."""
106
+ from pennyfarthing_scripts.consultation.dialogue_manager import (
107
+ update_outcome_in_file,
108
+ )
109
+
110
+ dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
111
+ result = update_outcome_in_file(dialogue_path, exchange_num, outcome_value, note) # type: ignore[arg-type]
112
+ if result.success:
113
+ click.echo(f"Updated exchange {exchange_num} outcome to {outcome_value}")
114
+ else:
115
+ click.echo(f"Error: {result.error}", err=True)
116
+ raise SystemExit(1)
117
+
118
+
119
+ @consultation.command()
120
+ @click.argument("story_id")
121
+ def summarize(story_id: str) -> None:
122
+ """Refresh dialogue summary."""
123
+ from pennyfarthing_scripts.consultation.dialogue_manager import refresh_summary
124
+
125
+ dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
126
+ result = refresh_summary(dialogue_path)
127
+ if result.success:
128
+ total = result.data.get("totalExchanges", 0) if result.data else 0
129
+ click.echo(f"Summary refreshed ({total} exchanges)")
130
+ else:
131
+ click.echo(f"Error: {result.error}", err=True)
132
+ raise SystemExit(1)
133
+
134
+
135
+ @consultation.command()
136
+ @click.argument("story_id")
137
+ @click.option("--jira-key", default=None, help="Jira key for archive filename")
138
+ def archive(story_id: str, jira_key: str | None) -> None:
139
+ """Archive dialogue file."""
140
+ from pennyfarthing_scripts.consultation.dialogue_manager import archive_dialogue
141
+
142
+ dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
143
+ archive_dir = Path(".session") / "archive"
144
+ result = archive_dialogue(dialogue_path, archive_dir, jira_key=jira_key, story_id=story_id)
145
+ if result.success:
146
+ click.echo(f"Archived to {result.data.get('archivePath', archive_dir)}" if result.data else "Archived")
147
+ else:
148
+ click.echo(f"Error: {result.error}", err=True)
149
+ raise SystemExit(1)
@@ -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)