@pennyfarthing/core 11.2.2 → 11.3.2

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 (168) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/core/dist/cli/commands/doctor-legacy.test.js +2 -2
  4. package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
  5. package/packages/core/dist/cli/commands/doctor.d.ts +63 -0
  6. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  7. package/packages/core/dist/cli/commands/doctor.js +280 -43
  8. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  9. package/packages/core/dist/cli/commands/init.d.ts +12 -0
  10. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  11. package/packages/core/dist/cli/commands/init.js +45 -0
  12. package/packages/core/dist/cli/commands/init.js.map +1 -1
  13. package/packages/core/dist/cli/commands/pyproject-install.test.d.ts +19 -0
  14. package/packages/core/dist/cli/commands/pyproject-install.test.d.ts.map +1 -0
  15. package/packages/core/dist/cli/commands/pyproject-install.test.js +261 -0
  16. package/packages/core/dist/cli/commands/pyproject-install.test.js.map +1 -0
  17. package/packages/core/dist/cli/commands/update-consolidation.test.js +14 -6
  18. package/packages/core/dist/cli/commands/update-consolidation.test.js.map +1 -1
  19. package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
  20. package/packages/core/dist/cli/commands/update.js +5 -1
  21. package/packages/core/dist/cli/commands/update.js.map +1 -1
  22. package/packages/core/dist/cli/index.js +2 -0
  23. package/packages/core/dist/cli/index.js.map +1 -1
  24. package/packages/core/dist/cli/utils/python.d.ts +1 -0
  25. package/packages/core/dist/cli/utils/python.d.ts.map +1 -1
  26. package/packages/core/dist/cli/utils/python.js +22 -1
  27. package/packages/core/dist/cli/utils/python.js.map +1 -1
  28. package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts +17 -0
  29. package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts.map +1 -0
  30. package/packages/core/dist/cli/utils/settings-hook-migration.test.js +382 -0
  31. package/packages/core/dist/cli/utils/settings-hook-migration.test.js.map +1 -0
  32. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts +16 -0
  33. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts.map +1 -0
  34. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js +377 -0
  35. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js.map +1 -0
  36. package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
  37. package/packages/core/dist/cli/utils/settings.js +15 -2
  38. package/packages/core/dist/cli/utils/settings.js.map +1 -1
  39. package/packages/core/dist/server/paths.d.ts.map +1 -1
  40. package/packages/core/dist/server/paths.js +6 -0
  41. package/packages/core/dist/server/paths.js.map +1 -1
  42. package/packages/core/dist/server/settings.d.ts.map +1 -1
  43. package/packages/core/dist/server/settings.js +5 -0
  44. package/packages/core/dist/server/settings.js.map +1 -1
  45. package/packages/core/dist/workflow/tandem-workflow-templates.test.js +7 -5
  46. package/packages/core/dist/workflow/tandem-workflow-templates.test.js.map +1 -1
  47. package/packages/core/dist/workflow/workflow-graph-validation.d.ts +65 -0
  48. package/packages/core/dist/workflow/workflow-graph-validation.d.ts.map +1 -0
  49. package/packages/core/dist/workflow/workflow-graph-validation.js +190 -0
  50. package/packages/core/dist/workflow/workflow-graph-validation.js.map +1 -0
  51. package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts +18 -0
  52. package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts.map +1 -0
  53. package/packages/core/dist/workflow/workflow-graph-validation.test.js +706 -0
  54. package/packages/core/dist/workflow/workflow-graph-validation.test.js.map +1 -0
  55. package/packages/core/dist/workflow/workflow-migration.test.js +6 -5
  56. package/packages/core/dist/workflow/workflow-migration.test.js.map +1 -1
  57. package/pennyfarthing-dist/agents/dev.md +4 -2
  58. package/pennyfarthing-dist/agents/devops.md +2 -10
  59. package/pennyfarthing-dist/agents/reviewer-preflight.md +4 -5
  60. package/pennyfarthing-dist/agents/sm.md +4 -17
  61. package/pennyfarthing-dist/commands/pf-health-check.md +30 -11
  62. package/pennyfarthing-dist/gates/{confidence-sm.md → confidence.md} +16 -17
  63. package/pennyfarthing-dist/gates/dev-exit.md +75 -0
  64. package/pennyfarthing-dist/gates/merge-ready.md +49 -0
  65. package/pennyfarthing-dist/gates/release-ready.md +95 -0
  66. package/pennyfarthing-dist/gates/reviewer-preflight-check.md +90 -0
  67. package/pennyfarthing-dist/gates/sm-setup-exit.md +82 -0
  68. package/pennyfarthing-dist/guides/agent-behavior.md +88 -30
  69. package/pennyfarthing-dist/guides/gates.md +7 -2
  70. package/pennyfarthing-dist/scripts/lib/find-root.sh +5 -0
  71. package/pennyfarthing-dist/scripts/lib/run-pf.sh +7 -0
  72. package/pennyfarthing-dist/skills/pf-settings/skill.md +42 -0
  73. package/pennyfarthing-dist/skills/skill-registry.yaml +15 -0
  74. package/pennyfarthing-dist/templates/pyproject.toml +27 -0
  75. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +7 -3
  76. package/pennyfarthing-dist/workflows/bdd.yaml +7 -3
  77. package/pennyfarthing-dist/workflows/installation-check/steps/step-01-foundation.md +77 -0
  78. package/pennyfarthing-dist/workflows/installation-check/steps/step-02-commands.md +82 -0
  79. package/pennyfarthing-dist/workflows/installation-check/steps/step-03-hooks.md +121 -0
  80. package/pennyfarthing-dist/workflows/installation-check/steps/step-04-scripts.md +83 -0
  81. package/pennyfarthing-dist/workflows/installation-check/steps/step-05-layout.md +81 -0
  82. package/pennyfarthing-dist/workflows/installation-check/steps/step-06-legacy.md +94 -0
  83. package/pennyfarthing-dist/workflows/installation-check/steps/step-07-tools.md +80 -0
  84. package/pennyfarthing-dist/workflows/installation-check/steps/step-08-summary.md +99 -0
  85. package/pennyfarthing-dist/workflows/installation-check/workflow.yaml +47 -0
  86. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +7 -3
  87. package/pennyfarthing-dist/workflows/tdd.yaml +7 -3
  88. package/pennyfarthing-dist/workflows/trivial.yaml +7 -3
  89. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  90. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  91. package/pennyfarthing_scripts/__pycache__/context.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/bc/__pycache__/focus.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/bc/__pycache__/split.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/bc/cli.py +21 -0
  96. package/pennyfarthing_scripts/bc/focus.py +1 -0
  97. package/pennyfarthing_scripts/bc/split.py +52 -0
  98. package/pennyfarthing_scripts/bikerack/__pycache__/audit_log_panel.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/bikerack/__pycache__/context_meter_footer.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  103. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  104. package/pennyfarthing_scripts/bikerack/__pycache__/events.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/bikerack/__pycache__/portrait_resolver.cpython-314.pyc +0 -0
  107. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  110. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/bikerack/context_meter_footer.py +53 -3
  112. package/pennyfarthing_scripts/bikerack/tui.py +202 -8
  113. package/pennyfarthing_scripts/bmad/__init__.py +1 -0
  114. package/pennyfarthing_scripts/bmad/__pycache__/__init__.cpython-314.pyc +0 -0
  115. package/pennyfarthing_scripts/bmad/__pycache__/cli.cpython-314.pyc +0 -0
  116. package/pennyfarthing_scripts/bmad/__pycache__/parser.cpython-314.pyc +0 -0
  117. package/pennyfarthing_scripts/bmad/__pycache__/sync.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/bmad/__pycache__/test_parser.cpython-314-pytest-9.0.2.pyc +0 -0
  119. package/pennyfarthing_scripts/bmad/__pycache__/test_sync.cpython-314-pytest-9.0.2.pyc +0 -0
  120. package/pennyfarthing_scripts/bmad/cli.py +197 -0
  121. package/pennyfarthing_scripts/bmad/importer.py +200 -0
  122. package/pennyfarthing_scripts/bmad/parser.py +233 -0
  123. package/pennyfarthing_scripts/bmad/sync.py +464 -0
  124. package/pennyfarthing_scripts/bmad/test_parser.py +253 -0
  125. package/pennyfarthing_scripts/bmad/test_sync.py +223 -0
  126. package/pennyfarthing_scripts/cli.py +10 -0
  127. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/handoff/__pycache__/gate_file.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/handoff/__pycache__/gate_runner.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/handoff/__pycache__/marker.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/hooks/__pycache__/bell_mode.cpython-314.pyc +0 -0
  137. package/pennyfarthing_scripts/hooks/__pycache__/cli.cpython-314.pyc +0 -0
  138. package/pennyfarthing_scripts/hooks/__pycache__/context_breaker.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/hooks/__pycache__/context_warning.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/hooks/__pycache__/cyclist_pretooluse.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/hooks/__pycache__/pre_edit_check.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/hooks/__pycache__/reflector_check.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/hooks/__pycache__/schema_validation.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/hooks/__pycache__/session_stop.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/hooks/__pycache__/sprint_yaml_validation.cpython-314.pyc +0 -0
  147. package/pennyfarthing_scripts/hooks/__pycache__/statusline.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/settings/__init__.py +0 -0
  150. package/pennyfarthing_scripts/settings/__pycache__/__init__.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/settings/__pycache__/cli.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/settings/__pycache__/settings.cpython-314.pyc +0 -0
  153. package/pennyfarthing_scripts/settings/cli.py +55 -0
  154. package/pennyfarthing_scripts/settings/settings.py +98 -0
  155. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  156. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  157. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  158. package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_evaluation.cpython-314-pytest-9.0.2.pyc +0 -0
  159. package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_gate.cpython-314-pytest-9.0.2.pyc +0 -0
  160. package/pennyfarthing_scripts/tests/__pycache__/test_gate_file_resolution.cpython-314-pytest-9.0.2.pyc +0 -0
  161. package/pennyfarthing_scripts/tests/__pycache__/test_gate_runner.cpython-314-pytest-9.0.2.pyc +0 -0
  162. package/pennyfarthing_scripts/tests/__pycache__/test_resolve_gate_file_field.cpython-314-pytest-9.0.2.pyc +0 -0
  163. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_list_team.cpython-314.pyc +0 -0
  164. package/pennyfarthing_scripts/tests/test_confidence_sm_gate.py +17 -16
  165. package/pennyfarthing_scripts/tests/test_resolve_gate_file_field.py +45 -47
  166. package/pennyfarthing_scripts/tests/test_workflow_list_team.py +0 -4
  167. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  168. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
@@ -23,6 +23,10 @@ workflow:
23
23
  - name: setup
24
24
  agent: sm
25
25
  output: [session_file, branches, story_context]
26
+ gate:
27
+ file: gates/sm-setup-exit
28
+ type: sm_setup_exit
29
+ condition: Session file created with workflow, phase, context, and branch
26
30
 
27
31
  - name: red
28
32
  agent: tea
@@ -41,9 +45,9 @@ workflow:
41
45
  input: [failing_tests, story_context]
42
46
  output: [implementation, passing_tests]
43
47
  gate:
44
- file: gates/tests-pass
45
- type: tests_pass
46
- condition: All tests passing, no skipped tests
48
+ file: gates/dev-exit
49
+ type: dev_exit
50
+ condition: Tests green, tree clean, no debug code, correct branch
47
51
  tandem:
48
52
  partner: architect
49
53
  scope: file-watch
@@ -13,6 +13,10 @@ workflow:
13
13
  - name: setup
14
14
  agent: sm
15
15
  output: [session_file, branches, story_context]
16
+ gate:
17
+ file: gates/sm-setup-exit
18
+ type: sm_setup_exit
19
+ condition: Session file created with workflow, phase, context, and branch
16
20
 
17
21
  - name: red
18
22
  agent: tea
@@ -28,9 +32,9 @@ workflow:
28
32
  input: [failing_tests, story_context]
29
33
  output: [implementation, passing_tests]
30
34
  gate:
31
- file: gates/tests-pass
32
- type: tests_pass
33
- condition: All tests passing, no skipped tests
35
+ file: gates/dev-exit
36
+ type: dev_exit
37
+ condition: Tests green, tree clean, no debug code, correct branch
34
38
 
35
39
  - name: verify
36
40
  agent: tea
@@ -13,15 +13,19 @@ workflow:
13
13
  - name: setup
14
14
  agent: sm
15
15
  output: [session_file, branches]
16
+ gate:
17
+ file: gates/sm-setup-exit
18
+ type: sm_setup_exit
19
+ condition: Session file created with workflow, phase, and branch
16
20
 
17
21
  - name: implement
18
22
  agent: dev
19
23
  input: [session_file]
20
24
  output: [implementation]
21
25
  gate:
22
- file: gates/tests-pass
23
- type: tests_pass
24
- condition: Existing tests still pass
26
+ file: gates/dev-exit
27
+ type: dev_exit
28
+ condition: Tests green, tree clean, no debug code, correct branch
25
29
 
26
30
  - name: review
27
31
  agent: reviewer
@@ -29,6 +29,7 @@ from pennyfarthing_scripts.bc.focus import (
29
29
  save_named_layout,
30
30
  set_panel_focus,
31
31
  )
32
+ from pennyfarthing_scripts.bc.split import set_split_layout
32
33
 
33
34
 
34
35
  def _get_current_layout() -> dict | None:
@@ -206,6 +207,26 @@ def clear_layout(name: str, dry_run: bool):
206
207
  sys.exit(1)
207
208
 
208
209
 
210
+ @bc.command("split")
211
+ @click.argument("left")
212
+ @click.argument("right")
213
+ @click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
214
+ def split_layout(left: str, right: str, dry_run: bool):
215
+ """Set split-pane layout with LEFT and RIGHT panels.
216
+
217
+ Example: pf bc split sprint diffs
218
+ """
219
+ if dry_run:
220
+ click.echo(json.dumps({"dry_run": True, "action": "split", "left": left, "right": right}))
221
+ return
222
+ result = set_split_layout(left, right)
223
+ if result["success"]:
224
+ click.echo(json.dumps({"success": True, "split": result["data"]}))
225
+ else:
226
+ click.echo(json.dumps({"success": False, "error": result["error"]}), err=True)
227
+ sys.exit(1)
228
+
229
+
209
230
  @bc.command("clear-all")
210
231
  @click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
211
232
  def clear_all_layouts(dry_run: bool):
@@ -34,6 +34,7 @@ VALID_PANELS = [
34
34
  "changed",
35
35
  "ac",
36
36
  "debug",
37
+ "progress",
37
38
  "settings",
38
39
  "tty",
39
40
  ]
@@ -0,0 +1,52 @@
1
+ """Split layout management — read/write split config in config.local.yaml.
2
+
3
+ Story 110-4: /bc split <left> <right> command support.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from pennyfarthing_scripts.bc.focus import (
11
+ VALID_PANELS,
12
+ _read_config,
13
+ _write_config,
14
+ )
15
+
16
+
17
+ def set_split_layout(
18
+ left: str, right: str, project_dir: Path | None = None
19
+ ) -> dict:
20
+ """Set split layout in config.local.yaml.
21
+
22
+ Args:
23
+ left: Panel for left pane (must be in VALID_PANELS).
24
+ right: Panel for right pane (must be in VALID_PANELS).
25
+ project_dir: Override project root (for testing).
26
+
27
+ Returns:
28
+ {success: bool, data?: dict, error?: str}
29
+ """
30
+ if left not in VALID_PANELS:
31
+ return {
32
+ "success": False,
33
+ "error": f"Invalid panel '{left}'. Valid panels: {', '.join(VALID_PANELS)}",
34
+ }
35
+ if right not in VALID_PANELS:
36
+ return {
37
+ "success": False,
38
+ "error": f"Invalid panel '{right}'. Valid panels: {', '.join(VALID_PANELS)}",
39
+ }
40
+ if left == right:
41
+ return {
42
+ "success": False,
43
+ "error": f"Left and right panels must be different (both are '{left}'). Use same panel in single mode instead.",
44
+ }
45
+
46
+ try:
47
+ config_path, config = _read_config(project_dir)
48
+ config["split"] = {"left": left, "right": right}
49
+ _write_config(config_path, config)
50
+ return {"success": True, "data": {"left": left, "right": right}}
51
+ except Exception as exc:
52
+ return {"success": False, "error": str(exc)}
@@ -4,11 +4,15 @@ Story 110-5: Context meter footer bar. Displays context window usage
4
4
  percentage with color-coded tier thresholds, always visible at the
5
5
  bottom of the layout.
6
6
 
7
+ Story 110-12: Periodic refresh timer, event-driven redraws, and
8
+ throttling for more frequent updates without performance degradation.
9
+
7
10
  Subscribes to /ws/context WebSocket channel.
8
11
  """
9
12
 
10
13
  from __future__ import annotations
11
14
 
15
+ import time
12
16
  from typing import Any
13
17
 
14
18
  from rich.text import Text
@@ -35,34 +39,80 @@ class ContextMeterFooter(Static):
35
39
  #: WebSocket channel this footer subscribes to
36
40
  channel: str = "context"
37
41
 
42
+ #: Seconds between periodic refresh redraws
43
+ refresh_interval: float = 5.0
44
+
45
+ #: Minimum seconds between redraws (throttle)
46
+ min_redraw_interval: float = 0.25
47
+
38
48
  def __init__(self, client: Any = None, **kwargs: Any) -> None:
39
49
  super().__init__(**kwargs)
40
50
  self._client = client
41
51
  self._context_data: dict[str, Any] | None = None
42
52
  self._mounted = False
53
+ self._refresh_timer: Any = None
54
+ self._last_redraw_time: float = 0.0
55
+ self.last_update_time: float = 0.0
56
+
57
+ @property
58
+ def is_stale(self) -> bool:
59
+ """True if no context data has been received."""
60
+ return self._context_data is None
43
61
 
44
62
  def on_mount(self) -> None:
45
- """Subscribe to context channel on mount."""
63
+ """Subscribe to context channel and start periodic refresh on mount."""
46
64
  self._mounted = True
47
65
  if self._client is not None:
48
66
  self._client.subscribe("context", self.handle_context_message)
67
+ self._client.on_state_change(self.on_connection_state_change)
68
+ try:
69
+ self._refresh_timer = self.set_interval(
70
+ self.refresh_interval, self.request_refresh
71
+ )
72
+ except RuntimeError:
73
+ # No running event loop (e.g. unit tests calling on_mount directly)
74
+ pass
49
75
 
50
76
  def on_unmount(self) -> None:
51
- """Mark as unmounted so further messages are ignored."""
77
+ """Stop timer and mark as unmounted so further messages are ignored."""
52
78
  self._mounted = False
79
+ if self._refresh_timer is not None:
80
+ self._refresh_timer.stop()
53
81
 
54
82
  def on_context_meter_footer_meter_update(self, event: MeterUpdate) -> None:
55
83
  """Process MeterUpdate in Textual message context — triggers repaint."""
56
84
  self.update(event.content)
57
85
 
86
+ def request_refresh(self) -> None:
87
+ """Redraw from cached context data. Safe to call at any time."""
88
+ if not self._mounted or self._context_data is None:
89
+ return
90
+ try:
91
+ rendered = self.render_meter(self._context_data)
92
+ self.post_message(self.MeterUpdate(rendered))
93
+ except Exception:
94
+ pass
95
+
96
+ def on_connection_state_change(self, state: Any) -> None:
97
+ """Redraw on WebSocket reconnection."""
98
+ self.request_refresh()
99
+
58
100
  def handle_context_message(self, msg: dict[str, Any] | None) -> None:
59
- """Process incoming /ws/context message."""
101
+ """Process incoming /ws/context message with throttling."""
60
102
  if not self._mounted or msg is None:
61
103
  return
62
104
  ctx = msg.get("context")
63
105
  if ctx is None:
64
106
  return
65
107
  self._context_data = ctx
108
+ self.last_update_time = time.monotonic()
109
+
110
+ # Throttle: skip redraw if too soon after last one
111
+ now = time.monotonic()
112
+ if now - self._last_redraw_time < self.min_redraw_interval:
113
+ return
114
+ self._last_redraw_time = now
115
+
66
116
  try:
67
117
  rendered = self.render_meter(ctx)
68
118
  self.post_message(self.MeterUpdate(rendered))
@@ -106,6 +106,14 @@ PANEL_DISPLAY_NAMES: dict[str, str] = {
106
106
  # Keys from PANEL_REGISTRY for fast lookup
107
107
  _PANEL_KEYS = [key for key, _ in PANEL_REGISTRY]
108
108
 
109
+ # Split-pane layout presets: name → (left_panel, right_panel)
110
+ # Story 110-4: Named presets for common side-by-side views.
111
+ SPLIT_PRESETS: dict[str, tuple[str, str]] = {
112
+ "sprint+diffs": ("sprint", "diffs"),
113
+ "changed+diffs": ("changed", "diffs"),
114
+ "progress+debug": ("progress", "debug"),
115
+ }
116
+
109
117
 
110
118
  class BindingFooter(Footer):
111
119
  """Footer subclass that exposes active binding text via render().
@@ -360,9 +368,10 @@ class BikeRackApp(App):
360
368
  class FocusUpdate(Message, bubble=False):
361
369
  """Focus change from WS — routed through Textual message system."""
362
370
 
363
- def __init__(self, focus: str | None) -> None:
371
+ def __init__(self, focus: str | None, split_config: dict | None = None) -> None:
364
372
  super().__init__()
365
373
  self.focus = focus
374
+ self.split_config = split_config
366
375
 
367
376
  class WsStateUpdate(Message, bubble=False):
368
377
  """WS connection state change — routed through Textual message system."""
@@ -413,12 +422,23 @@ class BikeRackApp(App):
413
422
  ContextMeterFooter {
414
423
  height: 1;
415
424
  }
425
+ #split-container {
426
+ display: none;
427
+ height: 1fr;
428
+ }
429
+ #split-left {
430
+ width: 1fr;
431
+ }
432
+ #split-right {
433
+ width: 1fr;
434
+ }
416
435
  """
417
436
 
418
437
  COMMANDS = App.COMMANDS | {PanelCommands}
419
438
 
420
439
  BINDINGS = [
421
440
  Binding("q", "quit", "Quit"),
441
+ Binding("shift+s", "toggle_split", "Split"),
422
442
  Binding("1", "switch_panel('sprint')", "Sprint", show=False),
423
443
  Binding("2", "switch_panel('git')", "Git", show=False),
424
444
  Binding("3", "switch_panel('diffs')", "Diffs", show=False),
@@ -429,8 +449,8 @@ class BikeRackApp(App):
429
449
  Binding("8", "switch_panel('progress')", "Progress", show=False),
430
450
  Binding("bracketright", "next_panel", "]Next"),
431
451
  Binding("bracketleft", "prev_panel", "[Prev"),
432
- Binding("tab", "next_panel", show=False),
433
- Binding("shift+tab", "prev_panel", show=False),
452
+ Binding("tab", "next_panel", show=False, priority=True),
453
+ Binding("shift+tab", "prev_panel", show=False, priority=True),
434
454
  Binding("n", "next_diff_file", "Next file", show=False),
435
455
  Binding("p", "prev_diff_file", "Prev file", show=False),
436
456
  Binding("j", "next_epic", show=False),
@@ -448,6 +468,12 @@ class BikeRackApp(App):
448
468
  self._focused_panel: str = "sprint"
449
469
  self._previous_panel: str | None = None
450
470
  self._programmatic_tab_count: int = 0
471
+ self._context_meter: ContextMeterFooter | None = None
472
+ # Split-pane state (Story 110-4)
473
+ self._split_mode: bool = False
474
+ self._active_split_pane: str = "left"
475
+ self._split_left_key: str = "sprint"
476
+ self._split_right_key: str = "diffs"
451
477
 
452
478
  def compose(self) -> ComposeResult:
453
479
  project_dir_name = Path(
@@ -470,7 +496,11 @@ class BikeRackApp(App):
470
496
  yield AuditLogPanel(client=self._client, id="panel-audit-log")
471
497
  yield DebugPanel(client=self._client, id="panel-debug")
472
498
  yield ProgressPanel(client=self._client, id="panel-progress")
473
- yield ContextMeterFooter(client=self._client)
499
+ with Horizontal(id="split-container"):
500
+ yield VerticalScroll(id="split-left")
501
+ yield VerticalScroll(id="split-right")
502
+ self._context_meter = ContextMeterFooter(client=self._client)
503
+ yield self._context_meter
474
504
  yield BindingFooter()
475
505
 
476
506
  async def on_mount(self) -> None:
@@ -543,8 +573,27 @@ class BikeRackApp(App):
543
573
  save_last_panel(key, project_dir=None)
544
574
  self._update_tab_bar(key)
545
575
 
576
+ # Refresh context meter on panel switch (110-12)
577
+ if self._context_meter is not None:
578
+ self._context_meter.request_refresh()
579
+
546
580
  def action_next_panel(self) -> None:
547
- """Cycle to the next panel."""
581
+ """Cycle to the next panel, or toggle pane focus in split mode."""
582
+ if self._split_mode:
583
+ # In split mode, Tab toggles between left and right pane
584
+ if self._active_split_pane == "left":
585
+ self._active_split_pane = "right"
586
+ try:
587
+ self.query_one("#split-right").focus()
588
+ except Exception:
589
+ pass
590
+ else:
591
+ self._active_split_pane = "left"
592
+ try:
593
+ self.query_one("#split-left").focus()
594
+ except Exception:
595
+ pass
596
+ return
548
597
  try:
549
598
  idx = _PANEL_KEYS.index(self._focused_panel)
550
599
  except ValueError:
@@ -606,6 +655,131 @@ class BikeRackApp(App):
606
655
  except Exception:
607
656
  pass
608
657
 
658
+ # ------------------------------------------------------------------
659
+ # Split-pane layout (Story 110-4)
660
+ # ------------------------------------------------------------------
661
+
662
+ def _sync_reparent(self, widget: Any, new_parent: Any) -> None:
663
+ """Move widget from current parent to new parent synchronously.
664
+
665
+ Uses internal Textual DOM API so the move is visible immediately
666
+ without awaiting an async mount/remove cycle.
667
+ """
668
+ old_parent = widget._parent
669
+ if old_parent is not None:
670
+ try:
671
+ old_parent._nodes._remove(widget)
672
+ except (ValueError, AttributeError):
673
+ # Fallback: remove from internal list directly
674
+ try:
675
+ old_parent._nodes._nodes.remove(widget)
676
+ except Exception:
677
+ pass
678
+ try:
679
+ new_parent._nodes._append(widget)
680
+ except AttributeError:
681
+ new_parent._nodes._nodes.append(widget)
682
+ widget._parent = new_parent
683
+
684
+ def _enter_split(self, left_key: str, right_key: str) -> None:
685
+ """Activate split mode with specified panels (synchronous)."""
686
+ if left_key not in _PANEL_KEYS or right_key not in _PANEL_KEYS:
687
+ return
688
+ if left_key == right_key:
689
+ return
690
+
691
+ self._split_mode = True
692
+ self._split_left_key = left_key
693
+ self._split_right_key = right_key
694
+ self._active_split_pane = "left"
695
+
696
+ try:
697
+ main_content = self.query_one("#main-content")
698
+ split_container = self.query_one("#split-container")
699
+ split_left = self.query_one("#split-left")
700
+ split_right = self.query_one("#split-right")
701
+ except Exception:
702
+ return
703
+
704
+ # Move left panel to split-left pane
705
+ try:
706
+ left_panel = self.query_one(f"#panel-{left_key}")
707
+ left_panel.display = True
708
+ self._sync_reparent(left_panel, split_left)
709
+ except Exception:
710
+ pass
711
+
712
+ # Move right panel to split-right pane
713
+ try:
714
+ right_panel = self.query_one(f"#panel-{right_key}")
715
+ right_panel.display = True
716
+ self._sync_reparent(right_panel, split_right)
717
+ except Exception:
718
+ pass
719
+
720
+ # Hide all other panels remaining in main-content
721
+ for panel_key in _PANEL_KEYS:
722
+ if panel_key not in (left_key, right_key):
723
+ try:
724
+ p = self.query_one(f"#panel-{panel_key}")
725
+ p.display = False
726
+ except Exception:
727
+ pass
728
+
729
+ main_content.display = False
730
+ split_container.display = True
731
+
732
+ def _exit_split(self) -> None:
733
+ """Deactivate split mode, return panels to main-content."""
734
+ self._split_mode = False
735
+
736
+ try:
737
+ main_content = self.query_one("#main-content")
738
+ split_container = self.query_one("#split-container")
739
+ split_left = self.query_one("#split-left")
740
+ split_right = self.query_one("#split-right")
741
+ except Exception:
742
+ return
743
+
744
+ # Move panels back from split panes to main-content
745
+ for pane in (split_left, split_right):
746
+ for child in list(pane.children):
747
+ self._sync_reparent(child, main_content)
748
+
749
+ split_container.display = False
750
+ main_content.display = True
751
+
752
+ # Restore single-panel visibility
753
+ for panel_key in _PANEL_KEYS:
754
+ try:
755
+ p = self.query_one(f"#panel-{panel_key}")
756
+ p.display = (panel_key == self._focused_panel)
757
+ except Exception:
758
+ pass
759
+
760
+ def action_toggle_split(self) -> None:
761
+ """Toggle split mode on/off (Shift+S keybinding)."""
762
+ if self._split_mode:
763
+ self._exit_split()
764
+ else:
765
+ # Default: current panel left, next panel right
766
+ left_key = self._focused_panel
767
+ try:
768
+ idx = _PANEL_KEYS.index(left_key)
769
+ except ValueError:
770
+ idx = 0
771
+ right_key = _PANEL_KEYS[(idx + 1) % len(_PANEL_KEYS)]
772
+ self._enter_split(left_key, right_key)
773
+
774
+ def action_apply_split_preset(self, name: str) -> None:
775
+ """Apply a named split preset (synchronous)."""
776
+ if name not in SPLIT_PRESETS:
777
+ return
778
+ left_key, right_key = SPLIT_PRESETS[name]
779
+ if self._split_mode:
780
+ self._exit_split()
781
+ self._enter_split(left_key, right_key)
782
+
609
783
  def _update_tab_bar(self, panel_key: str) -> None:
610
784
  """Update the tab bar widget with the given panel key.
611
785
 
@@ -638,7 +812,10 @@ class BikeRackApp(App):
638
812
  def _handle_focus_message(self, message: dict[str, Any] | None) -> None:
639
813
  """Handle incoming focus channel messages.
640
814
 
641
- Expected format: {type: 'init'|'update', focus: '<panel>'|null}
815
+ Expected format:
816
+ Single panel: {type: 'update', focus: '<panel>'}
817
+ Split layout: {type: 'update', focus: 'split', split: {left: ..., right: ...}}
818
+ Split preset: {type: 'update', focus: 'split:<preset-name>'}
642
819
  Only 'update' messages trigger panel switches (matching React hook).
643
820
  Routes through Textual message system via post_message for proper repaint.
644
821
  """
@@ -648,7 +825,8 @@ class BikeRackApp(App):
648
825
  return
649
826
  if "focus" not in message:
650
827
  return
651
- self.post_message(self.FocusUpdate(message["focus"]))
828
+ split_config = message.get("split")
829
+ self.post_message(self.FocusUpdate(message["focus"], split_config=split_config))
652
830
 
653
831
  def _handle_persona_message(self, message: dict[str, Any] | None) -> None:
654
832
  """Handle incoming persona channel messages.
@@ -677,7 +855,23 @@ class BikeRackApp(App):
677
855
  def on_bike_rack_app_focus_update(self, event: FocusUpdate) -> None:
678
856
  """Apply focus change in Textual message context."""
679
857
  focus = event.focus
680
- if focus is not None and focus in _PANEL_KEYS:
858
+ split_config = event.split_config
859
+
860
+ if focus == "split" and split_config:
861
+ # Explicit split layout: {focus: "split", split: {left: ..., right: ...}}
862
+ left = split_config.get("left", "sprint")
863
+ right = split_config.get("right", "diffs")
864
+ if self._split_mode:
865
+ self._exit_split()
866
+ self._enter_split(left, right)
867
+ elif focus is not None and focus.startswith("split:"):
868
+ # Preset reference: {focus: "split:progress+debug"}
869
+ preset_name = focus[len("split:"):]
870
+ self.action_apply_split_preset(preset_name)
871
+ elif focus is not None and focus in _PANEL_KEYS:
872
+ # Single panel focus — exit split if active
873
+ if self._split_mode:
874
+ self._exit_split()
681
875
  self.action_switch_panel(focus)
682
876
  elif focus is not None:
683
877
  self._previous_panel = self._focused_panel
@@ -0,0 +1 @@
1
+ # BMAD adapter — bidirectional sprint sync between BMAD markdown and PF YAML.