@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,152 @@
1
+ """
2
+ Git hooks installer — Python replacement for install-git-hooks.sh (145 lines).
3
+
4
+ Creates .d/ dispatcher directories, symlinks pennyfarthing hooks,
5
+ and migrates existing user hooks.
6
+
7
+ Usage via CLI:
8
+ pf git install-hooks
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from pennyfarthing_scripts.common.config import get_project_root
17
+
18
+ DISPATCHER_MARKER = "pennyfarthing-dispatcher"
19
+ PF_PREFIX = "10"
20
+ MIGRATED_PREFIX = "50"
21
+
22
+ # Hook source file → git hook name
23
+ HOOKS = [
24
+ ("pre-commit.sh", "pre-commit"),
25
+ ("pre-push.sh", "pre-push"),
26
+ ("post-merge.sh", "post-merge"),
27
+ ]
28
+
29
+
30
+ def _generate_dispatcher(template_path: Path, hook_name: str) -> str:
31
+ """Generate a dispatcher script from the template."""
32
+ template = template_path.read_text()
33
+ return template.replace("__HOOK_NAME__", hook_name)
34
+
35
+
36
+ def install_git_hooks(project_root: Path | None = None) -> int:
37
+ """Install git hooks with .d/ dispatcher pattern.
38
+
39
+ Creates .d/ directories for each hook, installs dispatcher scripts,
40
+ and symlinks pennyfarthing hooks into the .d/ directories.
41
+ Existing non-pennyfarthing hooks are migrated into .d/.
42
+
43
+ Args:
44
+ project_root: Project root. Auto-detected if not provided.
45
+
46
+ Returns:
47
+ 0 on success, 1 on error
48
+ """
49
+ if project_root is None:
50
+ project_root = get_project_root()
51
+
52
+ pf_dist = project_root / "pennyfarthing-dist"
53
+ if not pf_dist.is_dir():
54
+ print("Error: This script requires pennyfarthing-dist/ at the project root")
55
+ print(" End-user projects should use: pennyfarthing init")
56
+ return 1
57
+
58
+ git_dir = project_root / ".git"
59
+ if not git_dir.is_dir():
60
+ print("Error: Not a git repository")
61
+ return 1
62
+
63
+ hooks_source = pf_dist / "scripts" / "hooks"
64
+ hooks_dest = git_dir / "hooks"
65
+ hooks_dest.mkdir(exist_ok=True)
66
+
67
+ dispatcher_template = hooks_source / "dispatcher-template.sh"
68
+ if not dispatcher_template.is_file():
69
+ print(f"Error: dispatcher-template.sh not found at {dispatcher_template}")
70
+ return 1
71
+
72
+ print("Installing git hooks with .d/ dispatcher pattern...")
73
+ print(f" Source: pennyfarthing-dist/scripts/hooks/")
74
+ print(f" Dest: .git/hooks/")
75
+ print()
76
+
77
+ for source_file, dest_name in HOOKS:
78
+ source_path = hooks_source / source_file
79
+ dest_path = hooks_dest / dest_name
80
+ d_dir = hooks_dest / f"{dest_name}.d"
81
+ pf_hook_name = f"{PF_PREFIX}-pennyfarthing-{dest_name}.sh"
82
+ pf_hook_path = d_dir / pf_hook_name
83
+
84
+ if not source_path.is_file():
85
+ print(f" SKIP {dest_name} (source not found)")
86
+ continue
87
+
88
+ # Create .d/ directory
89
+ d_dir.mkdir(exist_ok=True)
90
+
91
+ # Handle existing hook at dest path
92
+ if dest_path.exists():
93
+ if dest_path.is_file():
94
+ content = dest_path.read_text()
95
+ if DISPATCHER_MARKER in content:
96
+ print(f" OK {dest_name} dispatcher (already installed)")
97
+ elif dest_path.is_symlink():
98
+ # Old-style symlink — replace with dispatcher
99
+ dest_path.unlink()
100
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
101
+ dest_path.chmod(0o755)
102
+ print(f" UPD {dest_name} -> dispatcher")
103
+ elif "pennyfarthing" in content:
104
+ # Old pennyfarthing single-file hook — replace
105
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
106
+ dest_path.chmod(0o755)
107
+ print(f" UPD {dest_name} -> dispatcher (was single-file pf hook)")
108
+ else:
109
+ # Non-pennyfarthing hook — migrate into .d/
110
+ migrated_name = f"{MIGRATED_PREFIX}-migrated-{dest_name}.sh"
111
+ migrated_path = d_dir / migrated_name
112
+ if not migrated_path.exists():
113
+ dest_path.rename(migrated_path)
114
+ migrated_path.chmod(0o755)
115
+ print(f" MIG {dest_name} -> {dest_name}.d/{migrated_name}")
116
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
117
+ dest_path.chmod(0o755)
118
+ print(f" NEW {dest_name} dispatcher")
119
+ elif dest_path.is_symlink():
120
+ dest_path.unlink()
121
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
122
+ dest_path.chmod(0o755)
123
+ print(f" UPD {dest_name} -> dispatcher")
124
+ else:
125
+ # No existing hook — install fresh dispatcher
126
+ dest_path.write_text(_generate_dispatcher(dispatcher_template, dest_name))
127
+ dest_path.chmod(0o755)
128
+ print(f" NEW {dest_name} dispatcher")
129
+
130
+ # Symlink pennyfarthing hook into .d/
131
+ # Relative path from .git/hooks/{hook}.d/ to pennyfarthing-dist/scripts/hooks/
132
+ relative_path = Path("../../../pennyfarthing-dist/scripts/hooks") / source_file
133
+
134
+ if pf_hook_path.is_symlink():
135
+ current_target = pf_hook_path.readlink()
136
+ if current_target == relative_path:
137
+ print(f" OK {dest_name}.d/{pf_hook_name} (already linked)")
138
+ else:
139
+ pf_hook_path.unlink()
140
+ pf_hook_path.symlink_to(relative_path)
141
+ print(f" UPD {dest_name}.d/{pf_hook_name} -> {relative_path}")
142
+ elif pf_hook_path.is_file():
143
+ pf_hook_path.unlink()
144
+ pf_hook_path.symlink_to(relative_path)
145
+ print(f" UPD {dest_name}.d/{pf_hook_name} -> {relative_path} (was copy)")
146
+ else:
147
+ pf_hook_path.symlink_to(relative_path)
148
+ print(f" NEW {dest_name}.d/{pf_hook_name} -> {relative_path}")
149
+
150
+ print()
151
+ print("Done. Verify with: ls -la .git/hooks/*.d/")
152
+ return 0
@@ -0,0 +1,196 @@
1
+ """
2
+ Repos.yaml loader — Python replacement for repo-utils.sh (778 lines).
3
+
4
+ Reads .pennyfarthing/repos.yaml and provides structured access to repo
5
+ configuration: paths, types, branches, build/test commands, dependencies.
6
+
7
+ Usage:
8
+ from pennyfarthing_scripts.git.repos import load_repos_config, get_repo_paths
9
+
10
+ config = load_repos_config()
11
+ for name, repo in config.items():
12
+ print(f"{name}: {repo.path} ({repo.default_branch})")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from pennyfarthing_scripts.common.config import get_project_root
24
+
25
+
26
+ @dataclass
27
+ class RepoConfig:
28
+ """Configuration for a single repository."""
29
+
30
+ name: str
31
+ path: str # Relative to project root (e.g., "." or "pennyfarthing")
32
+ repo_type: str # "orchestrator", "framework", "api", "ui", etc.
33
+ default_branch: str # "main" for trunk-based, "develop" for gitflow
34
+ branch_strategy: str # "trunk-based" or "gitflow"
35
+ description: str = ""
36
+ language: str = "unknown"
37
+ test_command: str = ""
38
+ build_command: str = ""
39
+ lint_command: str = ""
40
+ test_filter_flag: str = ""
41
+ dependencies: list[str] = field(default_factory=list)
42
+ owns: list[str] = field(default_factory=list)
43
+ never_edit: list[str] = field(default_factory=list)
44
+ ui_layer: str = "none"
45
+
46
+ @property
47
+ def is_gitflow(self) -> bool:
48
+ return self.branch_strategy == "gitflow"
49
+
50
+ @property
51
+ def upstream_ref(self) -> str:
52
+ """Remote ref to compare against for unpushed commits."""
53
+ return f"origin/{self.default_branch}"
54
+
55
+
56
+ def _parse_repo_entry(name: str, data: dict[str, Any] | None) -> RepoConfig:
57
+ """Parse a single repo entry from repos.yaml."""
58
+ if data is None:
59
+ data = {}
60
+ return RepoConfig(
61
+ name=name,
62
+ path=data.get("path", name),
63
+ repo_type=data.get("type", "unknown"),
64
+ default_branch=data.get("default_branch", "main"),
65
+ branch_strategy=data.get("branch_strategy", "trunk-based"),
66
+ description=data.get("description", ""),
67
+ language=data.get("language", "unknown"),
68
+ test_command=data.get("test_command", ""),
69
+ build_command=data.get("build_command", ""),
70
+ lint_command=data.get("lint_command", ""),
71
+ test_filter_flag=data.get("test_filter_flag", ""),
72
+ dependencies=data.get("dependencies", []) or [],
73
+ owns=data.get("owns", []) or [],
74
+ never_edit=data.get("never_edit", []) or [],
75
+ ui_layer=data.get("ui_layer", "none"),
76
+ )
77
+
78
+
79
+ def load_repos_config(project_root: Path | None = None) -> dict[str, RepoConfig]:
80
+ """Load repos.yaml and return a dict of name -> RepoConfig.
81
+
82
+ Args:
83
+ project_root: Project root directory. Auto-detected if not provided.
84
+
85
+ Returns:
86
+ Ordered dict of repo name -> RepoConfig.
87
+ Empty dict if repos.yaml not found.
88
+ """
89
+ if project_root is None:
90
+ project_root = get_project_root()
91
+
92
+ repos_path = project_root / ".pennyfarthing" / "repos.yaml"
93
+ if not repos_path.exists():
94
+ return {}
95
+
96
+ with open(repos_path) as f:
97
+ config = yaml.safe_load(f)
98
+
99
+ if not config or "repos" not in config:
100
+ return {}
101
+
102
+ repos: dict[str, RepoConfig] = {}
103
+ for name, data in config["repos"].items():
104
+ repos[name] = _parse_repo_entry(name, data)
105
+
106
+ return repos
107
+
108
+
109
+ def get_repo_paths(project_root: Path | None = None) -> list[tuple[str, Path]]:
110
+ """Get list of (name, absolute_path) tuples for all configured repos.
111
+
112
+ Args:
113
+ project_root: Project root directory. Auto-detected if not provided.
114
+
115
+ Returns:
116
+ List of (repo_name, absolute_path) tuples.
117
+ """
118
+ if project_root is None:
119
+ project_root = get_project_root()
120
+
121
+ repos = load_repos_config(project_root)
122
+ result = []
123
+ for name, repo in repos.items():
124
+ abs_path = (project_root / repo.path).resolve()
125
+ if abs_path.exists():
126
+ result.append((name, abs_path))
127
+ return result
128
+
129
+
130
+ def get_default_branch(
131
+ repo_name: str, project_root: Path | None = None
132
+ ) -> str:
133
+ """Get the default branch for a specific repo.
134
+
135
+ Args:
136
+ repo_name: Name of the repo in repos.yaml.
137
+ project_root: Project root directory. Auto-detected if not provided.
138
+
139
+ Returns:
140
+ Default branch name (e.g., "main" or "develop").
141
+ Falls back to "main" if repo not found.
142
+ """
143
+ repos = load_repos_config(project_root)
144
+ if repo_name in repos:
145
+ return repos[repo_name].default_branch
146
+ return "main"
147
+
148
+
149
+ def get_repo_config(
150
+ repo_name: str, project_root: Path | None = None
151
+ ) -> RepoConfig | None:
152
+ """Get the full config for a specific repo.
153
+
154
+ Args:
155
+ repo_name: Name of the repo in repos.yaml.
156
+ project_root: Project root directory. Auto-detected if not provided.
157
+
158
+ Returns:
159
+ RepoConfig or None if not found.
160
+ """
161
+ repos = load_repos_config(project_root)
162
+ return repos.get(repo_name)
163
+
164
+
165
+ def get_build_order(project_root: Path | None = None) -> list[str]:
166
+ """Get repos in build/dependency order.
167
+
168
+ Uses explicit build_order from repos.yaml if present,
169
+ otherwise returns repos in definition order.
170
+
171
+ Args:
172
+ project_root: Project root directory. Auto-detected if not provided.
173
+
174
+ Returns:
175
+ List of repo names in build order.
176
+ """
177
+ if project_root is None:
178
+ project_root = get_project_root()
179
+
180
+ repos_path = project_root / ".pennyfarthing" / "repos.yaml"
181
+ if not repos_path.exists():
182
+ return []
183
+
184
+ with open(repos_path) as f:
185
+ config = yaml.safe_load(f)
186
+
187
+ if not config:
188
+ return []
189
+
190
+ if "build_order" in config:
191
+ return config["build_order"]
192
+
193
+ if "repos" in config:
194
+ return list(config["repos"].keys())
195
+
196
+ return []
@@ -65,12 +65,15 @@ async def _run_git_command(args: list[str], cwd: Path) -> tuple[str, str, int]:
65
65
  )
66
66
 
67
67
 
68
- async def get_repo_status(name: str, path: Path) -> RepoStatus:
68
+ async def get_repo_status(
69
+ name: str, path: Path, upstream_ref: str = "origin/develop"
70
+ ) -> RepoStatus:
69
71
  """Get git status for a single repository.
70
72
 
71
73
  Args:
72
74
  name: Display name for the repo
73
75
  path: Path to the repository
76
+ upstream_ref: Remote ref to compare for unpushed commits (default: origin/develop)
74
77
 
75
78
  Returns:
76
79
  RepoStatus with current branch, changes, and unpushed commits
@@ -124,9 +127,9 @@ async def get_repo_status(name: str, path: Path) -> RepoStatus:
124
127
  status_out, _, _ = await _run_git_command(["status", "--short"], path)
125
128
  changes = [line for line in status_out.split("\n") if line.strip()]
126
129
 
127
- # Get unpushed commits (comparing to origin/develop)
130
+ # Get unpushed commits (comparing to upstream ref)
128
131
  unpushed_out, _, unpushed_rc = await _run_git_command(
129
- ["log", "origin/develop..HEAD", "--oneline"], path
132
+ ["log", f"{upstream_ref}..HEAD", "--oneline"], path
130
133
  )
131
134
  if unpushed_rc == 0 and unpushed_out:
132
135
  unpushed_commits = [
@@ -154,11 +157,13 @@ async def get_repo_status(name: str, path: Path) -> RepoStatus:
154
157
  )
155
158
 
156
159
 
157
- async def get_all_repo_status(repos: Sequence[tuple[str, Path]]) -> list[RepoStatus]:
160
+ async def get_all_repo_status(
161
+ repos: Sequence[tuple[str, Path, str] | tuple[str, Path]],
162
+ ) -> list[RepoStatus]:
158
163
  """Get git status for all repos in parallel using asyncio.gather.
159
164
 
160
165
  Args:
161
- repos: Sequence of (name, path) tuples for each repo
166
+ repos: Sequence of (name, path) or (name, path, upstream_ref) tuples
162
167
 
163
168
  Returns:
164
169
  List of RepoStatus objects in same order as input
@@ -166,7 +171,14 @@ async def get_all_repo_status(repos: Sequence[tuple[str, Path]]) -> list[RepoSta
166
171
  if not repos:
167
172
  return []
168
173
 
169
- tasks = [get_repo_status(name, path) for name, path in repos]
174
+ tasks = []
175
+ for entry in repos:
176
+ if len(entry) == 3:
177
+ name, path, upstream_ref = entry # type: ignore[misc]
178
+ tasks.append(get_repo_status(name, path, upstream_ref))
179
+ else:
180
+ name, path = entry # type: ignore[misc]
181
+ tasks.append(get_repo_status(name, path))
170
182
  results = await asyncio.gather(*tasks, return_exceptions=False)
171
183
  return list(results)
172
184
 
@@ -279,13 +291,17 @@ async def main(brief: bool = False) -> int:
279
291
  Returns:
280
292
  0 if all repos clean, 1 if any have changes/unpushed
281
293
  """
282
- from pennyfarthing_scripts.common.config import get_project_root
294
+ from pennyfarthing_scripts.git.repos import load_repos_config, get_repo_paths
295
+
296
+ repos_with_upstream: list[tuple[str, Path, str]] = []
297
+ repo_paths = get_repo_paths()
298
+ config = load_repos_config()
283
299
 
284
- # For now, just check the current project
285
- project_root = get_project_root()
286
- repos = [("pennyfarthing", project_root)]
300
+ for name, path in repo_paths:
301
+ upstream = config[name].upstream_ref if name in config else "origin/develop"
302
+ repos_with_upstream.append((name, path, upstream))
287
303
 
288
- statuses = await get_all_repo_status(repos)
304
+ statuses = await get_all_repo_status(repos_with_upstream)
289
305
 
290
306
  if brief:
291
307
  print(format_status_brief(statuses))