@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,811 @@
1
+ """Tests for Story 86-16: Port dialogue manager from TS/bash to Python.
2
+
3
+ RED state tests for the Python dialogue file persistence layer.
4
+ Ported from packages/core/src/consultation/dialogue-manager.test.ts.
5
+
6
+ Acceptance Criteria:
7
+ AC1: pennyfarthing_scripts/consultation/ package with dialogue_manager.py
8
+ implementing: create, format, parse, summary, append, update_outcome,
9
+ refresh_summary, archive
10
+ AC2: pf consultation Click CLI group with subcommands: init, append,
11
+ outcome, summarize, archive
12
+ AC3: All 6 ACs from 86-3 still pass — same file format, behavior, output
13
+ AC4: Tests ported to pytest in this file
14
+
15
+ Run with: pytest pennyfarthing_scripts/tests/test_dialogue_manager.py -v
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+ from click.testing import CliRunner
24
+
25
+ from pennyfarthing_scripts.consultation.dialogue_manager import (
26
+ DialogueExchange,
27
+ DialogueHeader,
28
+ DialogueResult,
29
+ append_exchange_to_file,
30
+ archive_dialogue,
31
+ create_dialogue_content,
32
+ format_exchange,
33
+ generate_summary,
34
+ parse_dialogue_exchanges,
35
+ refresh_summary,
36
+ update_outcome_in_file,
37
+ )
38
+
39
+ # =============================================================================
40
+ # Fixtures
41
+ # =============================================================================
42
+
43
+ VALID_HEADER = DialogueHeader(
44
+ story_id="86-3",
45
+ workflow="tdd-tandem",
46
+ leader="dev",
47
+ leader_character="Jack Torrance",
48
+ partner="architect",
49
+ partner_character="Andy Dufresne",
50
+ started_at="2026-02-16T10:00:00Z",
51
+ )
52
+
53
+ VALID_EXCHANGE = DialogueExchange(
54
+ number=1,
55
+ timestamp="10:05",
56
+ leader="dev",
57
+ partner="architect",
58
+ question="Should we use a class or functional approach for the dialogue manager?",
59
+ recommendation="Use pure functions — they are easier to test and align with the existing consultation-protocol.ts pattern",
60
+ confidence="high",
61
+ )
62
+
63
+ SECOND_EXCHANGE = DialogueExchange(
64
+ number=2,
65
+ timestamp="10:20",
66
+ leader="dev",
67
+ partner="architect",
68
+ question="Should the shell wrapper call Node.js or use pure bash?",
69
+ recommendation="Pure bash for the shell wrapper — keeps it dependency-free and consistent with other core scripts",
70
+ confidence="medium",
71
+ outcome="applied",
72
+ outcome_note="Implemented with sed/awk",
73
+ )
74
+
75
+
76
+ # =============================================================================
77
+ # AC1 + AC3-AC1: Dialogue file creation on first consultation
78
+ # =============================================================================
79
+
80
+
81
+ class TestCreateDialogueContent:
82
+ """AC1: create_dialogue_content — initial file header."""
83
+
84
+ def test_includes_story_id_in_title(self):
85
+ content = create_dialogue_content(VALID_HEADER)
86
+ assert "# Tandem Dialogue: 86-3" in content
87
+
88
+ def test_includes_workflow(self):
89
+ content = create_dialogue_content(VALID_HEADER)
90
+ assert "**Workflow:** tdd-tandem" in content
91
+
92
+ def test_includes_leader_agent(self):
93
+ content = create_dialogue_content(VALID_HEADER)
94
+ assert "**Leader:** dev" in content
95
+
96
+ def test_includes_partner_agent(self):
97
+ content = create_dialogue_content(VALID_HEADER)
98
+ assert "**Partner:** architect" in content
99
+
100
+ def test_includes_start_timestamp(self):
101
+ content = create_dialogue_content(VALID_HEADER)
102
+ assert "**Started:** 2026-02-16T10:00:00Z" in content
103
+
104
+ def test_includes_character_names_when_provided(self):
105
+ content = create_dialogue_content(VALID_HEADER)
106
+ assert "Jack Torrance" in content
107
+ assert "Andy Dufresne" in content
108
+
109
+ def test_handles_missing_character_names(self):
110
+ header = DialogueHeader(
111
+ story_id="86-3",
112
+ workflow="tdd-tandem",
113
+ leader="dev",
114
+ partner="architect",
115
+ started_at="2026-02-16T10:00:00Z",
116
+ )
117
+ content = create_dialogue_content(header)
118
+ assert "**Leader:** dev" in content
119
+ assert "**Partner:** architect" in content
120
+
121
+ def test_includes_empty_summary_section(self):
122
+ content = create_dialogue_content(VALID_HEADER)
123
+ assert "## Summary" in content
124
+ assert "**Total exchanges:** 0" in content
125
+
126
+ def test_includes_horizontal_rule_separator(self):
127
+ content = create_dialogue_content(VALID_HEADER)
128
+ assert "---" in content
129
+
130
+
131
+ # =============================================================================
132
+ # AC1 + AC3-AC2: Exchange format and appending
133
+ # =============================================================================
134
+
135
+
136
+ class TestFormatExchange:
137
+ """AC1: format_exchange — single exchange as markdown."""
138
+
139
+ def test_includes_exchange_number(self):
140
+ formatted = format_exchange(VALID_EXCHANGE)
141
+ assert "## Exchange 1" in formatted
142
+
143
+ def test_includes_timestamp_and_direction(self):
144
+ formatted = format_exchange(VALID_EXCHANGE)
145
+ assert "**[10:05] dev \u2192 architect**" in formatted
146
+
147
+ def test_includes_question_as_blockquote(self):
148
+ formatted = format_exchange(VALID_EXCHANGE)
149
+ assert "> Should we use a class or functional approach" in formatted
150
+
151
+ def test_includes_partner_response_header(self):
152
+ formatted = format_exchange(VALID_EXCHANGE)
153
+ assert "**[10:05] architect:**" in formatted
154
+
155
+ def test_includes_recommendation_text(self):
156
+ formatted = format_exchange(VALID_EXCHANGE)
157
+ assert "Use pure functions" in formatted
158
+
159
+ def test_includes_confidence_level(self):
160
+ formatted = format_exchange(VALID_EXCHANGE)
161
+ assert "**Confidence:** high" in formatted
162
+
163
+ def test_shows_pending_when_no_outcome(self):
164
+ formatted = format_exchange(VALID_EXCHANGE)
165
+ assert "**Outcome:** _pending_" in formatted
166
+
167
+ def test_includes_outcome_when_present(self):
168
+ formatted = format_exchange(SECOND_EXCHANGE)
169
+ assert "**Outcome:** applied" in formatted
170
+
171
+ def test_includes_outcome_note(self):
172
+ formatted = format_exchange(SECOND_EXCHANGE)
173
+ assert "Implemented with sed/awk" in formatted
174
+
175
+ def test_includes_separator(self):
176
+ formatted = format_exchange(VALID_EXCHANGE)
177
+ assert "---" in formatted
178
+
179
+ def test_outcome_without_note(self):
180
+ exchange = DialogueExchange(
181
+ number=3,
182
+ timestamp="11:00",
183
+ leader="dev",
184
+ partner="architect",
185
+ question="Q?",
186
+ recommendation="R.",
187
+ confidence="low",
188
+ outcome="rejected",
189
+ )
190
+ formatted = format_exchange(exchange)
191
+ assert "**Outcome:** rejected" in formatted
192
+
193
+
194
+ # =============================================================================
195
+ # AC1 + AC3-AC2: File append operations
196
+ # =============================================================================
197
+
198
+
199
+ class TestAppendExchangeToFile:
200
+ """AC1: append_exchange_to_file — file creation and exchange appending."""
201
+
202
+ def test_creates_file_on_first_append(self, tmp_path: Path):
203
+ dialogue_path = tmp_path / "86-3-dialogue.md"
204
+ result = append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
205
+
206
+ assert result.success is True
207
+ assert dialogue_path.exists()
208
+
209
+ def test_file_contains_header(self, tmp_path: Path):
210
+ dialogue_path = tmp_path / "86-3-dialogue.md"
211
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
212
+
213
+ content = dialogue_path.read_text()
214
+ assert "# Tandem Dialogue: 86-3" in content
215
+
216
+ def test_file_contains_first_exchange(self, tmp_path: Path):
217
+ dialogue_path = tmp_path / "86-3-dialogue.md"
218
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
219
+
220
+ content = dialogue_path.read_text()
221
+ assert "## Exchange 1" in content
222
+
223
+ def test_requires_header_for_new_file(self, tmp_path: Path):
224
+ dialogue_path = tmp_path / "86-3-dialogue.md"
225
+ result = append_exchange_to_file(dialogue_path, VALID_EXCHANGE)
226
+
227
+ assert result.success is False
228
+ assert result.error is not None
229
+ assert "Header required" in result.error or "header" in result.error.lower()
230
+
231
+ def test_appends_multiple_exchanges(self, tmp_path: Path):
232
+ dialogue_path = tmp_path / "86-3-dialogue.md"
233
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
234
+ append_exchange_to_file(dialogue_path, SECOND_EXCHANGE)
235
+
236
+ content = dialogue_path.read_text()
237
+ assert "## Exchange 1" in content
238
+ assert "## Exchange 2" in content
239
+
240
+ def test_exchanges_appear_before_summary(self, tmp_path: Path):
241
+ dialogue_path = tmp_path / "86-3-dialogue.md"
242
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
243
+
244
+ content = dialogue_path.read_text()
245
+ exchange_idx = content.index("## Exchange 1")
246
+ summary_idx = content.index("## Summary")
247
+ assert exchange_idx < summary_idx
248
+
249
+ def test_creates_parent_directories(self, tmp_path: Path):
250
+ dialogue_path = tmp_path / "nested" / "dir" / "86-3-dialogue.md"
251
+ result = append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
252
+
253
+ assert result.success is True
254
+ assert dialogue_path.exists()
255
+
256
+ def test_returns_exchange_number_in_data(self, tmp_path: Path):
257
+ dialogue_path = tmp_path / "86-3-dialogue.md"
258
+ result = append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
259
+
260
+ assert result.success is True
261
+ assert result.data is not None
262
+ assert result.data.get("exchangeNumber") == 1
263
+
264
+
265
+ # =============================================================================
266
+ # AC1 + AC3-AC3: Outcome tracking
267
+ # =============================================================================
268
+
269
+
270
+ class TestUpdateOutcomeInFile:
271
+ """AC1: update_outcome_in_file — outcome tracking."""
272
+
273
+ def test_updates_to_applied(self, tmp_path: Path):
274
+ dialogue_path = tmp_path / "86-3-dialogue.md"
275
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
276
+
277
+ result = update_outcome_in_file(dialogue_path, 1, "applied", "Used pure functions")
278
+
279
+ assert result.success is True
280
+ content = dialogue_path.read_text()
281
+ assert "**Outcome:** applied" in content
282
+ assert "Used pure functions" in content
283
+
284
+ def test_updates_to_deferred(self, tmp_path: Path):
285
+ dialogue_path = tmp_path / "86-3-dialogue.md"
286
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
287
+
288
+ update_outcome_in_file(dialogue_path, 1, "deferred", "Revisit in next phase")
289
+
290
+ content = dialogue_path.read_text()
291
+ assert "**Outcome:** deferred" in content
292
+
293
+ def test_updates_to_rejected(self, tmp_path: Path):
294
+ dialogue_path = tmp_path / "86-3-dialogue.md"
295
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
296
+
297
+ update_outcome_in_file(dialogue_path, 1, "rejected", "Went with class approach")
298
+
299
+ content = dialogue_path.read_text()
300
+ assert "**Outcome:** rejected" in content
301
+
302
+ def test_updates_without_note(self, tmp_path: Path):
303
+ dialogue_path = tmp_path / "86-3-dialogue.md"
304
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
305
+
306
+ result = update_outcome_in_file(dialogue_path, 1, "applied")
307
+
308
+ assert result.success is True
309
+ content = dialogue_path.read_text()
310
+ assert "**Outcome:** applied" in content
311
+
312
+ def test_fails_for_nonexistent_exchange(self, tmp_path: Path):
313
+ dialogue_path = tmp_path / "86-3-dialogue.md"
314
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
315
+
316
+ result = update_outcome_in_file(dialogue_path, 99, "applied")
317
+
318
+ assert result.success is False
319
+ assert result.error is not None
320
+ assert "99" in result.error
321
+
322
+ def test_fails_for_nonexistent_file(self, tmp_path: Path):
323
+ dialogue_path = tmp_path / "nonexistent-dialogue.md"
324
+
325
+ result = update_outcome_in_file(dialogue_path, 1, "applied")
326
+
327
+ assert result.success is False
328
+
329
+ def test_updates_correct_exchange_when_multiple_exist(self, tmp_path: Path):
330
+ dialogue_path = tmp_path / "86-3-dialogue.md"
331
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
332
+ append_exchange_to_file(dialogue_path, SECOND_EXCHANGE)
333
+
334
+ update_outcome_in_file(dialogue_path, 1, "rejected", "Changed mind")
335
+
336
+ content = dialogue_path.read_text()
337
+ # Exchange 1 should be rejected
338
+ # Exchange 2 should still be applied (from SECOND_EXCHANGE)
339
+ lines = content.split("\n")
340
+ in_exchange_1 = False
341
+ in_exchange_2 = False
342
+ exchange_1_outcome = ""
343
+ exchange_2_outcome = ""
344
+ for line in lines:
345
+ if "## Exchange 1" in line:
346
+ in_exchange_1 = True
347
+ in_exchange_2 = False
348
+ elif "## Exchange 2" in line:
349
+ in_exchange_1 = False
350
+ in_exchange_2 = True
351
+ elif "**Outcome:**" in line:
352
+ if in_exchange_1:
353
+ exchange_1_outcome = line
354
+ in_exchange_1 = False
355
+ elif in_exchange_2:
356
+ exchange_2_outcome = line
357
+ in_exchange_2 = False
358
+
359
+ assert "rejected" in exchange_1_outcome
360
+ assert "applied" in exchange_2_outcome
361
+
362
+
363
+ # =============================================================================
364
+ # AC1 + AC3-AC4: Summary generation
365
+ # =============================================================================
366
+
367
+
368
+ class TestGenerateSummary:
369
+ """AC1: generate_summary — auto-generated summary section."""
370
+
371
+ def test_includes_total_exchange_count(self):
372
+ exchanges = [VALID_EXCHANGE, SECOND_EXCHANGE]
373
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
374
+
375
+ assert "**Total exchanges:** 2" in summary
376
+
377
+ def test_includes_applied_decisions(self):
378
+ exchanges = [
379
+ DialogueExchange(
380
+ number=1, timestamp="10:05", leader="dev", partner="architect",
381
+ question="Q1", recommendation="R1", confidence="high",
382
+ outcome="applied", outcome_note="Went with pure functions",
383
+ ),
384
+ DialogueExchange(
385
+ number=2, timestamp="10:20", leader="dev", partner="architect",
386
+ question="Q2", recommendation="R2", confidence="medium",
387
+ outcome="applied", outcome_note="Implemented with sed/awk",
388
+ ),
389
+ ]
390
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
391
+
392
+ assert "Went with pure functions" in summary
393
+ assert "Implemented with sed/awk" in summary
394
+
395
+ def test_excludes_rejected_from_decisions(self):
396
+ exchanges = [
397
+ DialogueExchange(
398
+ number=1, timestamp="10:05", leader="dev", partner="architect",
399
+ question="Q1", recommendation="R1", confidence="high",
400
+ outcome="rejected", outcome_note="Did not adopt this",
401
+ ),
402
+ DialogueExchange(
403
+ number=2, timestamp="10:20", leader="dev", partner="architect",
404
+ question="Q2", recommendation="R2", confidence="medium",
405
+ outcome="applied", outcome_note="Adopted this one",
406
+ ),
407
+ ]
408
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
409
+
410
+ assert "Did not adopt this" not in summary
411
+ assert "Adopted this one" in summary
412
+
413
+ def test_calculates_time_span(self):
414
+ exchanges = [
415
+ DialogueExchange(
416
+ number=1, timestamp="10:05", leader="dev", partner="architect",
417
+ question="Q1", recommendation="R1", confidence="high",
418
+ ),
419
+ DialogueExchange(
420
+ number=2, timestamp="10:35", leader="dev", partner="architect",
421
+ question="Q2", recommendation="R2", confidence="medium",
422
+ ),
423
+ ]
424
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
425
+
426
+ assert "30m" in summary
427
+
428
+ def test_single_exchange_time(self):
429
+ exchanges = [VALID_EXCHANGE]
430
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
431
+
432
+ assert "**Time in tandem:**" in summary
433
+
434
+ def test_no_applied_shows_none(self):
435
+ exchanges = [
436
+ DialogueExchange(
437
+ number=1, timestamp="10:05", leader="dev", partner="architect",
438
+ question="Q1", recommendation="R1", confidence="high",
439
+ outcome="deferred",
440
+ ),
441
+ ]
442
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
443
+
444
+ assert "None" in summary
445
+
446
+ def test_includes_summary_marker(self):
447
+ exchanges = [VALID_EXCHANGE]
448
+ summary = generate_summary(exchanges, "2026-02-16T10:00:00Z")
449
+
450
+ assert "## Summary" in summary
451
+
452
+
453
+ # =============================================================================
454
+ # AC1: refresh_summary (file operation)
455
+ # =============================================================================
456
+
457
+
458
+ class TestRefreshSummary:
459
+ """AC1: refresh_summary — regenerate summary in existing file."""
460
+
461
+ def test_refreshes_summary_with_exchange_count(self, tmp_path: Path):
462
+ dialogue_path = tmp_path / "86-3-dialogue.md"
463
+ exchange_with_outcome = DialogueExchange(
464
+ number=1, timestamp="10:05", leader="dev", partner="architect",
465
+ question="Q?", recommendation="R.", confidence="high",
466
+ outcome="applied", outcome_note="Adopted functional approach",
467
+ )
468
+ append_exchange_to_file(dialogue_path, exchange_with_outcome, VALID_HEADER)
469
+
470
+ result = refresh_summary(dialogue_path)
471
+
472
+ assert result.success is True
473
+ content = dialogue_path.read_text()
474
+ assert "**Total exchanges:** 1" in content
475
+
476
+ def test_refreshed_summary_includes_applied_decisions(self, tmp_path: Path):
477
+ dialogue_path = tmp_path / "86-3-dialogue.md"
478
+ exchange_with_outcome = DialogueExchange(
479
+ number=1, timestamp="10:05", leader="dev", partner="architect",
480
+ question="Q?", recommendation="R.", confidence="high",
481
+ outcome="applied", outcome_note="Adopted functional approach",
482
+ )
483
+ append_exchange_to_file(dialogue_path, exchange_with_outcome, VALID_HEADER)
484
+
485
+ refresh_summary(dialogue_path)
486
+
487
+ content = dialogue_path.read_text()
488
+ assert "Adopted functional approach" in content
489
+
490
+ def test_fails_for_nonexistent_file(self, tmp_path: Path):
491
+ dialogue_path = tmp_path / "nonexistent-dialogue.md"
492
+
493
+ result = refresh_summary(dialogue_path)
494
+
495
+ assert result.success is False
496
+
497
+ def test_returns_total_in_data(self, tmp_path: Path):
498
+ dialogue_path = tmp_path / "86-3-dialogue.md"
499
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
500
+ append_exchange_to_file(dialogue_path, SECOND_EXCHANGE)
501
+
502
+ result = refresh_summary(dialogue_path)
503
+
504
+ assert result.success is True
505
+ assert result.data is not None
506
+ assert result.data.get("totalExchanges") == 2
507
+
508
+
509
+ # =============================================================================
510
+ # AC1 + AC3-AC5: Dialogue archival
511
+ # =============================================================================
512
+
513
+
514
+ class TestArchiveDialogue:
515
+ """AC1: archive_dialogue — copy to archive directory."""
516
+
517
+ def test_copies_to_archive_with_jira_key(self, tmp_path: Path):
518
+ dialogue_path = tmp_path / "86-3-dialogue.md"
519
+ archive_dir = tmp_path / "archive"
520
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
521
+
522
+ result = archive_dialogue(dialogue_path, archive_dir, jira_key="MSSCI-15200")
523
+
524
+ assert result.success is True
525
+ assert (archive_dir / "MSSCI-15200-dialogue.md").exists()
526
+
527
+ def test_uses_story_id_when_no_jira_key(self, tmp_path: Path):
528
+ dialogue_path = tmp_path / "86-3-dialogue.md"
529
+ archive_dir = tmp_path / "archive"
530
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
531
+
532
+ result = archive_dialogue(dialogue_path, archive_dir, story_id="86-3")
533
+
534
+ assert result.success is True
535
+ assert (archive_dir / "86-3-dialogue.md").exists()
536
+
537
+ def test_preserves_content(self, tmp_path: Path):
538
+ dialogue_path = tmp_path / "86-3-dialogue.md"
539
+ archive_dir = tmp_path / "archive"
540
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
541
+ original = dialogue_path.read_text()
542
+
543
+ archive_dialogue(dialogue_path, archive_dir, jira_key="MSSCI-15200")
544
+
545
+ archived = (archive_dir / "MSSCI-15200-dialogue.md").read_text()
546
+ assert archived == original
547
+
548
+ def test_creates_archive_directory(self, tmp_path: Path):
549
+ dialogue_path = tmp_path / "86-3-dialogue.md"
550
+ archive_dir = tmp_path / "new-archive"
551
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
552
+
553
+ result = archive_dialogue(dialogue_path, archive_dir, jira_key="MSSCI-15200")
554
+
555
+ assert result.success is True
556
+ assert archive_dir.exists()
557
+
558
+ def test_fails_for_nonexistent_source(self, tmp_path: Path):
559
+ dialogue_path = tmp_path / "nonexistent-dialogue.md"
560
+ archive_dir = tmp_path / "archive"
561
+
562
+ result = archive_dialogue(dialogue_path, archive_dir, jira_key="MSSCI-15200")
563
+
564
+ assert result.success is False
565
+ assert result.error is not None
566
+
567
+ def test_uses_unknown_prefix_when_no_key_or_id(self, tmp_path: Path):
568
+ dialogue_path = tmp_path / "86-3-dialogue.md"
569
+ archive_dir = tmp_path / "archive"
570
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
571
+
572
+ result = archive_dialogue(dialogue_path, archive_dir)
573
+
574
+ assert result.success is True
575
+ assert (archive_dir / "unknown-dialogue.md").exists()
576
+
577
+
578
+ # =============================================================================
579
+ # AC1 + AC3-AC6: Readable format / round-trip parsing
580
+ # =============================================================================
581
+
582
+
583
+ class TestParseDialogueExchanges:
584
+ """AC1: parse_dialogue_exchanges — round-trip format fidelity."""
585
+
586
+ def test_parses_single_exchange(self, tmp_path: Path):
587
+ dialogue_path = tmp_path / "86-3-dialogue.md"
588
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
589
+
590
+ content = dialogue_path.read_text()
591
+ parsed = parse_dialogue_exchanges(content)
592
+
593
+ assert len(parsed) == 1
594
+ assert parsed[0].number == 1
595
+
596
+ def test_parses_multiple_exchanges(self, tmp_path: Path):
597
+ dialogue_path = tmp_path / "86-3-dialogue.md"
598
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
599
+ append_exchange_to_file(dialogue_path, SECOND_EXCHANGE)
600
+
601
+ content = dialogue_path.read_text()
602
+ parsed = parse_dialogue_exchanges(content)
603
+
604
+ assert len(parsed) == 2
605
+ assert parsed[0].number == 1
606
+ assert parsed[1].number == 2
607
+
608
+ def test_parses_exchange_fields(self, tmp_path: Path):
609
+ dialogue_path = tmp_path / "86-3-dialogue.md"
610
+ append_exchange_to_file(dialogue_path, SECOND_EXCHANGE, VALID_HEADER)
611
+
612
+ content = dialogue_path.read_text()
613
+ parsed = parse_dialogue_exchanges(content)
614
+
615
+ assert parsed[0].leader == "dev"
616
+ assert parsed[0].partner == "architect"
617
+ assert parsed[0].timestamp == "10:20"
618
+ assert parsed[0].outcome == "applied"
619
+
620
+ def test_parses_pending_outcome_as_none(self, tmp_path: Path):
621
+ dialogue_path = tmp_path / "86-3-dialogue.md"
622
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
623
+
624
+ content = dialogue_path.read_text()
625
+ parsed = parse_dialogue_exchanges(content)
626
+
627
+ assert parsed[0].outcome is None
628
+
629
+ def test_round_trip_question(self, tmp_path: Path):
630
+ dialogue_path = tmp_path / "86-3-dialogue.md"
631
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
632
+
633
+ content = dialogue_path.read_text()
634
+ parsed = parse_dialogue_exchanges(content)
635
+
636
+ assert parsed[0].question == VALID_EXCHANGE.question
637
+
638
+ def test_round_trip_recommendation(self, tmp_path: Path):
639
+ dialogue_path = tmp_path / "86-3-dialogue.md"
640
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
641
+
642
+ content = dialogue_path.read_text()
643
+ parsed = parse_dialogue_exchanges(content)
644
+
645
+ assert parsed[0].recommendation == VALID_EXCHANGE.recommendation
646
+
647
+ def test_round_trip_outcome_with_note(self, tmp_path: Path):
648
+ dialogue_path = tmp_path / "86-3-dialogue.md"
649
+ exchange = DialogueExchange(
650
+ number=1, timestamp="10:05", leader="dev", partner="architect",
651
+ question="Q?", recommendation="R.", confidence="high",
652
+ outcome="applied", outcome_note="Adopted this approach",
653
+ )
654
+ append_exchange_to_file(dialogue_path, exchange, VALID_HEADER)
655
+
656
+ content = dialogue_path.read_text()
657
+ parsed = parse_dialogue_exchanges(content)
658
+
659
+ assert parsed[0].outcome == "applied"
660
+ assert parsed[0].outcome_note == "Adopted this approach"
661
+
662
+ def test_parses_from_pure_formatted_content(self):
663
+ """Parse directly from format_exchange output, no file I/O."""
664
+ content = create_dialogue_content(VALID_HEADER)
665
+ formatted = format_exchange(VALID_EXCHANGE)
666
+ # Insert before summary
667
+ summary_idx = content.index("## Summary")
668
+ full_content = content[:summary_idx] + formatted + "\n" + content[summary_idx:]
669
+
670
+ parsed = parse_dialogue_exchanges(full_content)
671
+
672
+ assert len(parsed) == 1
673
+ assert parsed[0].number == 1
674
+
675
+
676
+ # =============================================================================
677
+ # AC1: Result format compliance ({success, data?, error?})
678
+ # =============================================================================
679
+
680
+
681
+ class TestResultFormat:
682
+ """All file operations return DialogueResult with success/data/error."""
683
+
684
+ def test_append_returns_result(self, tmp_path: Path):
685
+ dialogue_path = tmp_path / "86-3-dialogue.md"
686
+ result = append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
687
+
688
+ assert isinstance(result, DialogueResult)
689
+ assert isinstance(result.success, bool)
690
+
691
+ def test_update_outcome_returns_result(self, tmp_path: Path):
692
+ dialogue_path = tmp_path / "86-3-dialogue.md"
693
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
694
+ result = update_outcome_in_file(dialogue_path, 1, "applied")
695
+
696
+ assert isinstance(result, DialogueResult)
697
+ assert isinstance(result.success, bool)
698
+
699
+ def test_refresh_summary_returns_result(self, tmp_path: Path):
700
+ dialogue_path = tmp_path / "86-3-dialogue.md"
701
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
702
+ result = refresh_summary(dialogue_path)
703
+
704
+ assert isinstance(result, DialogueResult)
705
+ assert isinstance(result.success, bool)
706
+
707
+ def test_archive_returns_result(self, tmp_path: Path):
708
+ dialogue_path = tmp_path / "86-3-dialogue.md"
709
+ archive_dir = tmp_path / "archive"
710
+ append_exchange_to_file(dialogue_path, VALID_EXCHANGE, VALID_HEADER)
711
+ result = archive_dialogue(dialogue_path, archive_dir, jira_key="MSSCI-15200")
712
+
713
+ assert isinstance(result, DialogueResult)
714
+ assert isinstance(result.success, bool)
715
+
716
+
717
+ # =============================================================================
718
+ # AC1: Markdown structure (well-formed output)
719
+ # =============================================================================
720
+
721
+
722
+ class TestMarkdownStructure:
723
+ """Verify output is well-formed markdown."""
724
+
725
+ def test_content_starts_with_h1(self):
726
+ content = create_dialogue_content(VALID_HEADER)
727
+ assert content.startswith("# Tandem Dialogue:")
728
+
729
+ def test_exchange_starts_with_h2(self):
730
+ formatted = format_exchange(VALID_EXCHANGE)
731
+ assert formatted.startswith("## Exchange")
732
+
733
+ def test_summary_starts_with_h2(self):
734
+ summary = generate_summary([VALID_EXCHANGE], "2026-02-16T10:00:00Z")
735
+ assert summary.startswith("## Summary")
736
+
737
+
738
+ # =============================================================================
739
+ # AC2: Click CLI subcommands
740
+ # =============================================================================
741
+
742
+
743
+ class TestConsultationCLI:
744
+ """AC2: pf consultation CLI group with subcommands."""
745
+
746
+ def setup_method(self):
747
+ self.runner = CliRunner()
748
+
749
+ def test_consultation_group_exists(self):
750
+ from pennyfarthing_scripts.cli import cli
751
+
752
+ result = self.runner.invoke(cli, ["consultation", "--help"])
753
+ assert result.exit_code == 0
754
+ assert "consultation" in result.output.lower() or "dialogue" in result.output.lower()
755
+
756
+ def test_init_subcommand_exists(self):
757
+ from pennyfarthing_scripts.cli import cli
758
+
759
+ result = self.runner.invoke(cli, ["consultation", "init", "--help"])
760
+ assert result.exit_code == 0
761
+
762
+ def test_append_subcommand_exists(self):
763
+ from pennyfarthing_scripts.cli import cli
764
+
765
+ result = self.runner.invoke(cli, ["consultation", "append", "--help"])
766
+ assert result.exit_code == 0
767
+
768
+ def test_outcome_subcommand_exists(self):
769
+ from pennyfarthing_scripts.cli import cli
770
+
771
+ result = self.runner.invoke(cli, ["consultation", "outcome", "--help"])
772
+ assert result.exit_code == 0
773
+
774
+ def test_summarize_subcommand_exists(self):
775
+ from pennyfarthing_scripts.cli import cli
776
+
777
+ result = self.runner.invoke(cli, ["consultation", "summarize", "--help"])
778
+ assert result.exit_code == 0
779
+
780
+ def test_archive_subcommand_exists(self):
781
+ from pennyfarthing_scripts.cli import cli
782
+
783
+ result = self.runner.invoke(cli, ["consultation", "archive", "--help"])
784
+ assert result.exit_code == 0
785
+
786
+ def test_init_creates_dialogue_file(self, tmp_path: Path):
787
+ """CLI init should create a dialogue file in .session/."""
788
+ from pennyfarthing_scripts.cli import cli
789
+
790
+ with self.runner.isolated_filesystem(temp_dir=tmp_path):
791
+ session_dir = Path(".session")
792
+ session_dir.mkdir()
793
+ result = self.runner.invoke(
794
+ cli,
795
+ ["consultation", "init", "86-3", "tdd-tandem", "dev", "architect"],
796
+ )
797
+ # Should succeed (exit 0) and create the file
798
+ assert result.exit_code == 0
799
+ dialogue_file = session_dir / "86-3-dialogue.md"
800
+ assert dialogue_file.exists()
801
+
802
+ def test_init_output_confirms_creation(self, tmp_path: Path):
803
+ from pennyfarthing_scripts.cli import cli
804
+
805
+ with self.runner.isolated_filesystem(temp_dir=tmp_path):
806
+ Path(".session").mkdir()
807
+ result = self.runner.invoke(
808
+ cli,
809
+ ["consultation", "init", "86-3", "tdd-tandem", "dev", "architect"],
810
+ )
811
+ assert "Created" in result.output or "created" in result.output