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