@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.
- package/README.md +8 -8
- package/package.json +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/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/check-context.sh +1 -1
- 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/worktree-manager.sh +5 -496
- 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/otel-auto-config.sh +9 -11
- package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
- package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
- 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-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/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.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/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/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/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,149 @@
|
|
|
1
|
+
"""Click CLI for consultation dialogue management.
|
|
2
|
+
|
|
3
|
+
Provides `pf consultation` group with subcommands matching
|
|
4
|
+
the bash wrapper dialogue-manager.sh interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def consultation():
|
|
17
|
+
"""Tandem consultation dialogue management.
|
|
18
|
+
|
|
19
|
+
\b
|
|
20
|
+
Subcommands:
|
|
21
|
+
init - Create dialogue file
|
|
22
|
+
append - Append exchange
|
|
23
|
+
outcome - Update outcome
|
|
24
|
+
summarize - Refresh summary
|
|
25
|
+
archive - Archive dialogue file
|
|
26
|
+
"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@consultation.command()
|
|
31
|
+
@click.argument("story_id")
|
|
32
|
+
@click.argument("workflow")
|
|
33
|
+
@click.argument("leader")
|
|
34
|
+
@click.argument("partner")
|
|
35
|
+
def init(story_id: str, workflow: str, leader: str, partner: str) -> None:
|
|
36
|
+
"""Create a new dialogue file."""
|
|
37
|
+
from pennyfarthing_scripts.consultation.dialogue_manager import (
|
|
38
|
+
DialogueHeader,
|
|
39
|
+
create_dialogue_content,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
header = DialogueHeader(
|
|
43
|
+
story_id=story_id,
|
|
44
|
+
workflow=workflow,
|
|
45
|
+
leader=leader,
|
|
46
|
+
partner=partner,
|
|
47
|
+
started_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
content = create_dialogue_content(header)
|
|
51
|
+
session_dir = Path(".session")
|
|
52
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
dialogue_path = session_dir / f"{story_id}-dialogue.md"
|
|
54
|
+
dialogue_path.write_text(content, encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
click.echo(f"Created {dialogue_path}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@consultation.command()
|
|
60
|
+
@click.argument("story_id")
|
|
61
|
+
@click.argument("question")
|
|
62
|
+
@click.argument("recommendation")
|
|
63
|
+
@click.argument("confidence")
|
|
64
|
+
def append(story_id: str, question: str, recommendation: str, confidence: str) -> None:
|
|
65
|
+
"""Append an exchange to a dialogue file."""
|
|
66
|
+
from pennyfarthing_scripts.consultation.dialogue_manager import (
|
|
67
|
+
DialogueExchange,
|
|
68
|
+
append_exchange_to_file,
|
|
69
|
+
parse_dialogue_exchanges,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
|
|
73
|
+
if not dialogue_path.exists():
|
|
74
|
+
click.echo(f"Dialogue file not found: {dialogue_path}", err=True)
|
|
75
|
+
raise SystemExit(1)
|
|
76
|
+
|
|
77
|
+
content = dialogue_path.read_text(encoding="utf-8")
|
|
78
|
+
existing = parse_dialogue_exchanges(content)
|
|
79
|
+
next_num = len(existing) + 1
|
|
80
|
+
|
|
81
|
+
exchange = DialogueExchange(
|
|
82
|
+
number=next_num,
|
|
83
|
+
timestamp=datetime.now(timezone.utc).strftime("%H:%M"),
|
|
84
|
+
leader="",
|
|
85
|
+
partner="",
|
|
86
|
+
question=question,
|
|
87
|
+
recommendation=recommendation,
|
|
88
|
+
confidence=confidence,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
result = append_exchange_to_file(dialogue_path, exchange)
|
|
92
|
+
if result.success:
|
|
93
|
+
click.echo(f"Appended exchange {next_num}")
|
|
94
|
+
else:
|
|
95
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
96
|
+
raise SystemExit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@consultation.command()
|
|
100
|
+
@click.argument("story_id")
|
|
101
|
+
@click.argument("exchange_num", type=int)
|
|
102
|
+
@click.argument("outcome_value")
|
|
103
|
+
@click.option("--note", default=None, help="Outcome note")
|
|
104
|
+
def outcome(story_id: str, exchange_num: int, outcome_value: str, note: str | None) -> None:
|
|
105
|
+
"""Update outcome of an exchange."""
|
|
106
|
+
from pennyfarthing_scripts.consultation.dialogue_manager import (
|
|
107
|
+
update_outcome_in_file,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
|
|
111
|
+
result = update_outcome_in_file(dialogue_path, exchange_num, outcome_value, note) # type: ignore[arg-type]
|
|
112
|
+
if result.success:
|
|
113
|
+
click.echo(f"Updated exchange {exchange_num} outcome to {outcome_value}")
|
|
114
|
+
else:
|
|
115
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@consultation.command()
|
|
120
|
+
@click.argument("story_id")
|
|
121
|
+
def summarize(story_id: str) -> None:
|
|
122
|
+
"""Refresh dialogue summary."""
|
|
123
|
+
from pennyfarthing_scripts.consultation.dialogue_manager import refresh_summary
|
|
124
|
+
|
|
125
|
+
dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
|
|
126
|
+
result = refresh_summary(dialogue_path)
|
|
127
|
+
if result.success:
|
|
128
|
+
total = result.data.get("totalExchanges", 0) if result.data else 0
|
|
129
|
+
click.echo(f"Summary refreshed ({total} exchanges)")
|
|
130
|
+
else:
|
|
131
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
132
|
+
raise SystemExit(1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@consultation.command()
|
|
136
|
+
@click.argument("story_id")
|
|
137
|
+
@click.option("--jira-key", default=None, help="Jira key for archive filename")
|
|
138
|
+
def archive(story_id: str, jira_key: str | None) -> None:
|
|
139
|
+
"""Archive dialogue file."""
|
|
140
|
+
from pennyfarthing_scripts.consultation.dialogue_manager import archive_dialogue
|
|
141
|
+
|
|
142
|
+
dialogue_path = Path(".session") / f"{story_id}-dialogue.md"
|
|
143
|
+
archive_dir = Path(".session") / "archive"
|
|
144
|
+
result = archive_dialogue(dialogue_path, archive_dir, jira_key=jira_key, story_id=story_id)
|
|
145
|
+
if result.success:
|
|
146
|
+
click.echo(f"Archived to {result.data.get('archivePath', archive_dir)}" if result.data else "Archived")
|
|
147
|
+
else:
|
|
148
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
149
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Dialogue file management for tandem agent consultation.
|
|
2
|
+
|
|
3
|
+
Persistence layer for consultation exchanges between tandem agents.
|
|
4
|
+
Port of packages/core/src/consultation/dialogue-manager.ts to Python.
|
|
5
|
+
|
|
6
|
+
All pure functions use the same markdown format defined in ADR-0012.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
Outcome = Literal["applied", "deferred", "rejected"]
|
|
18
|
+
|
|
19
|
+
SUMMARY_MARKER = "## Summary"
|
|
20
|
+
EXCHANGE_RE = re.compile(r"^## Exchange (\d+)")
|
|
21
|
+
OUTCOME_RE = re.compile(r"^\*\*Outcome:\*\*\s+(.+)")
|
|
22
|
+
DIRECTION_RE = re.compile(r"^\*\*\[(\d{2}:\d{2})\]\s+(\S+)\s+→\s+(\S+)\*\*")
|
|
23
|
+
PARTNER_RESP_RE = re.compile(r"^\*\*\[(\d{2}:\d{2})\]\s+(\S+):\*\*")
|
|
24
|
+
CONFIDENCE_RE = re.compile(r"^\*\*Confidence:\*\*\s+(\S+)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DialogueHeader:
|
|
29
|
+
story_id: str
|
|
30
|
+
workflow: str
|
|
31
|
+
leader: str
|
|
32
|
+
partner: str
|
|
33
|
+
leader_character: str | None = None
|
|
34
|
+
partner_character: str | None = None
|
|
35
|
+
started_at: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class DialogueExchange:
|
|
40
|
+
number: int
|
|
41
|
+
timestamp: str # HH:MM
|
|
42
|
+
leader: str
|
|
43
|
+
partner: str
|
|
44
|
+
question: str
|
|
45
|
+
recommendation: str
|
|
46
|
+
confidence: str
|
|
47
|
+
outcome: Outcome | None = None
|
|
48
|
+
outcome_note: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class DialogueResult:
|
|
53
|
+
success: bool
|
|
54
|
+
data: dict | None = None
|
|
55
|
+
error: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# Pure Functions
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_dialogue_content(header: DialogueHeader) -> str:
|
|
64
|
+
"""Create initial dialogue file content with header and empty summary."""
|
|
65
|
+
leader_label = (
|
|
66
|
+
f"{header.leader} ({header.leader_character})"
|
|
67
|
+
if header.leader_character
|
|
68
|
+
else header.leader
|
|
69
|
+
)
|
|
70
|
+
partner_label = (
|
|
71
|
+
f"{header.partner} ({header.partner_character})"
|
|
72
|
+
if header.partner_character
|
|
73
|
+
else header.partner
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
f"# Tandem Dialogue: {header.story_id}\n"
|
|
78
|
+
f"\n"
|
|
79
|
+
f"**Workflow:** {header.workflow}\n"
|
|
80
|
+
f"**Leader:** {leader_label} | **Partner:** {partner_label}\n"
|
|
81
|
+
f"**Started:** {header.started_at}\n"
|
|
82
|
+
f"\n"
|
|
83
|
+
f"---\n"
|
|
84
|
+
f"\n"
|
|
85
|
+
f"{SUMMARY_MARKER}\n"
|
|
86
|
+
f"- **Total exchanges:** 0\n"
|
|
87
|
+
f"- **Key decisions:** None\n"
|
|
88
|
+
f"- **Time in tandem:** 0m\n"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def format_exchange(exchange: DialogueExchange) -> str:
|
|
93
|
+
"""Format a single exchange as markdown block."""
|
|
94
|
+
if exchange.outcome:
|
|
95
|
+
outcome_text = f"**Outcome:** {exchange.outcome}"
|
|
96
|
+
if exchange.outcome_note:
|
|
97
|
+
outcome_text += f" - {exchange.outcome_note}"
|
|
98
|
+
else:
|
|
99
|
+
outcome_text = "**Outcome:** _pending_"
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
f"## Exchange {exchange.number}\n"
|
|
103
|
+
f"**[{exchange.timestamp}] {exchange.leader} \u2192 {exchange.partner}**\n"
|
|
104
|
+
f"\n"
|
|
105
|
+
f"> {exchange.question}\n"
|
|
106
|
+
f"\n"
|
|
107
|
+
f"**[{exchange.timestamp}] {exchange.partner}:**\n"
|
|
108
|
+
f"\n"
|
|
109
|
+
f"{exchange.recommendation}\n"
|
|
110
|
+
f"\n"
|
|
111
|
+
f"**Confidence:** {exchange.confidence}\n"
|
|
112
|
+
f"\n"
|
|
113
|
+
f"{outcome_text}\n"
|
|
114
|
+
f"\n"
|
|
115
|
+
f"---\n"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_dialogue_exchanges(content: str) -> list[DialogueExchange]:
|
|
120
|
+
"""Parse exchanges from dialogue file content."""
|
|
121
|
+
exchanges: list[DialogueExchange] = []
|
|
122
|
+
lines = content.split("\n")
|
|
123
|
+
|
|
124
|
+
current: dict | None = None
|
|
125
|
+
in_question = False
|
|
126
|
+
in_recommendation = False
|
|
127
|
+
question_lines: list[str] = []
|
|
128
|
+
rec_lines: list[str] = []
|
|
129
|
+
|
|
130
|
+
for line in lines:
|
|
131
|
+
# New exchange starts
|
|
132
|
+
m = EXCHANGE_RE.match(line)
|
|
133
|
+
if m:
|
|
134
|
+
if current is not None and "number" in current:
|
|
135
|
+
current["question"] = "\n".join(question_lines)
|
|
136
|
+
current["recommendation"] = "\n".join(rec_lines)
|
|
137
|
+
exchanges.append(DialogueExchange(**current))
|
|
138
|
+
current = {"number": int(m.group(1))}
|
|
139
|
+
question_lines = []
|
|
140
|
+
rec_lines = []
|
|
141
|
+
in_question = False
|
|
142
|
+
in_recommendation = False
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if current is None:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Leader → Partner direction line
|
|
149
|
+
dm = DIRECTION_RE.match(line)
|
|
150
|
+
if dm:
|
|
151
|
+
current["timestamp"] = dm.group(1)
|
|
152
|
+
current["leader"] = dm.group(2)
|
|
153
|
+
current["partner"] = dm.group(3)
|
|
154
|
+
in_question = True
|
|
155
|
+
in_recommendation = False
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# Partner response line
|
|
159
|
+
pm = PARTNER_RESP_RE.match(line)
|
|
160
|
+
if pm:
|
|
161
|
+
in_question = False
|
|
162
|
+
in_recommendation = True
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Confidence line
|
|
166
|
+
cm = CONFIDENCE_RE.match(line)
|
|
167
|
+
if cm:
|
|
168
|
+
current["confidence"] = cm.group(1)
|
|
169
|
+
in_recommendation = False
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Outcome line
|
|
173
|
+
om = OUTCOME_RE.match(line)
|
|
174
|
+
if om:
|
|
175
|
+
outcome_raw = om.group(1).strip()
|
|
176
|
+
if outcome_raw != "_pending_":
|
|
177
|
+
dash_idx = outcome_raw.find(" - ")
|
|
178
|
+
if dash_idx >= 0:
|
|
179
|
+
current["outcome"] = outcome_raw[:dash_idx].strip()
|
|
180
|
+
current["outcome_note"] = outcome_raw[dash_idx + 3 :].strip()
|
|
181
|
+
else:
|
|
182
|
+
current["outcome"] = outcome_raw
|
|
183
|
+
in_recommendation = False
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Collect question text (blockquote lines)
|
|
187
|
+
if in_question:
|
|
188
|
+
stripped = line[2:] if line.startswith("> ") else line
|
|
189
|
+
if stripped.strip():
|
|
190
|
+
question_lines.append(stripped)
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Collect recommendation text
|
|
194
|
+
if in_recommendation:
|
|
195
|
+
if line.strip():
|
|
196
|
+
rec_lines.append(line)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Push last exchange
|
|
200
|
+
if current is not None and "number" in current:
|
|
201
|
+
current["question"] = "\n".join(question_lines)
|
|
202
|
+
current["recommendation"] = "\n".join(rec_lines)
|
|
203
|
+
exchanges.append(DialogueExchange(**current))
|
|
204
|
+
|
|
205
|
+
return exchanges
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def generate_summary(exchanges: list[DialogueExchange], started_at: str) -> str:
|
|
209
|
+
"""Generate summary markdown section from exchanges."""
|
|
210
|
+
total = len(exchanges)
|
|
211
|
+
|
|
212
|
+
# Key decisions from applied outcomes
|
|
213
|
+
applied = [e for e in exchanges if e.outcome == "applied" and e.outcome_note]
|
|
214
|
+
if applied:
|
|
215
|
+
decisions_text = "\n".join(f" - {e.outcome_note}" for e in applied)
|
|
216
|
+
else:
|
|
217
|
+
decisions_text = "None"
|
|
218
|
+
|
|
219
|
+
# Time in tandem: span between first and last exchange timestamps
|
|
220
|
+
duration = "0m"
|
|
221
|
+
if exchanges:
|
|
222
|
+
first = _parse_time(exchanges[0].timestamp)
|
|
223
|
+
last = _parse_time(exchanges[-1].timestamp)
|
|
224
|
+
if first is not None and last is not None:
|
|
225
|
+
mins = last - first
|
|
226
|
+
duration = f"{mins}m" if mins > 0 else "0m"
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
f"{SUMMARY_MARKER}\n"
|
|
230
|
+
f"- **Total exchanges:** {total}\n"
|
|
231
|
+
f"- **Key decisions:**\n"
|
|
232
|
+
f"{decisions_text}\n"
|
|
233
|
+
f"- **Time in tandem:** {duration}\n"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# =============================================================================
|
|
238
|
+
# File Operations
|
|
239
|
+
# =============================================================================
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def append_exchange_to_file(
|
|
243
|
+
dialogue_path: Path,
|
|
244
|
+
exchange: DialogueExchange,
|
|
245
|
+
header: DialogueHeader | None = None,
|
|
246
|
+
) -> DialogueResult:
|
|
247
|
+
"""Append an exchange to a dialogue file. Creates the file if missing."""
|
|
248
|
+
try:
|
|
249
|
+
if not dialogue_path.exists():
|
|
250
|
+
if not header:
|
|
251
|
+
return DialogueResult(
|
|
252
|
+
success=False, error="Header required for new dialogue file"
|
|
253
|
+
)
|
|
254
|
+
initial = create_dialogue_content(header)
|
|
255
|
+
dialogue_path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
dialogue_path.write_text(initial, encoding="utf-8")
|
|
257
|
+
|
|
258
|
+
content = dialogue_path.read_text(encoding="utf-8")
|
|
259
|
+
formatted = format_exchange(exchange)
|
|
260
|
+
|
|
261
|
+
# Insert exchange before summary section
|
|
262
|
+
summary_idx = content.find(SUMMARY_MARKER)
|
|
263
|
+
if summary_idx < 0:
|
|
264
|
+
dialogue_path.write_text(
|
|
265
|
+
content + "\n" + formatted, encoding="utf-8"
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
before = content[:summary_idx]
|
|
269
|
+
after = content[summary_idx:]
|
|
270
|
+
dialogue_path.write_text(
|
|
271
|
+
before + formatted + "\n" + after, encoding="utf-8"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return DialogueResult(
|
|
275
|
+
success=True, data={"exchangeNumber": exchange.number}
|
|
276
|
+
)
|
|
277
|
+
except Exception as err:
|
|
278
|
+
return DialogueResult(
|
|
279
|
+
success=False, error=f"Failed to append exchange: {err}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def update_outcome_in_file(
|
|
284
|
+
dialogue_path: Path,
|
|
285
|
+
exchange_num: int,
|
|
286
|
+
outcome: Outcome,
|
|
287
|
+
note: str | None = None,
|
|
288
|
+
) -> DialogueResult:
|
|
289
|
+
"""Update the outcome of a specific exchange in the dialogue file."""
|
|
290
|
+
try:
|
|
291
|
+
if not dialogue_path.exists():
|
|
292
|
+
return DialogueResult(
|
|
293
|
+
success=False,
|
|
294
|
+
error=f"Dialogue file not found: {dialogue_path}",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
content = dialogue_path.read_text(encoding="utf-8")
|
|
298
|
+
lines = content.split("\n")
|
|
299
|
+
|
|
300
|
+
in_target = False
|
|
301
|
+
found = False
|
|
302
|
+
|
|
303
|
+
for i, line in enumerate(lines):
|
|
304
|
+
em = EXCHANGE_RE.match(line)
|
|
305
|
+
if em:
|
|
306
|
+
in_target = int(em.group(1)) == exchange_num
|
|
307
|
+
|
|
308
|
+
if in_target and OUTCOME_RE.match(line):
|
|
309
|
+
outcome_text = f"**Outcome:** {outcome}"
|
|
310
|
+
if note:
|
|
311
|
+
outcome_text += f" - {note}"
|
|
312
|
+
lines[i] = outcome_text
|
|
313
|
+
found = True
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
if not found:
|
|
317
|
+
return DialogueResult(
|
|
318
|
+
success=False,
|
|
319
|
+
error=f"Exchange {exchange_num} not found in dialogue file",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
dialogue_path.write_text("\n".join(lines), encoding="utf-8")
|
|
323
|
+
return DialogueResult(
|
|
324
|
+
success=True, data={"exchangeNum": exchange_num, "outcome": outcome}
|
|
325
|
+
)
|
|
326
|
+
except Exception as err:
|
|
327
|
+
return DialogueResult(
|
|
328
|
+
success=False, error=f"Failed to update outcome: {err}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def refresh_summary(dialogue_path: Path) -> DialogueResult:
|
|
333
|
+
"""Regenerate the summary section in an existing dialogue file."""
|
|
334
|
+
try:
|
|
335
|
+
if not dialogue_path.exists():
|
|
336
|
+
return DialogueResult(
|
|
337
|
+
success=False,
|
|
338
|
+
error=f"Dialogue file not found: {dialogue_path}",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
content = dialogue_path.read_text(encoding="utf-8")
|
|
342
|
+
exchanges = parse_dialogue_exchanges(content)
|
|
343
|
+
|
|
344
|
+
# Extract startedAt from header
|
|
345
|
+
started_match = re.search(r"\*\*Started:\*\*\s+(.+)", content)
|
|
346
|
+
started_at = (
|
|
347
|
+
started_match.group(1).strip() if started_match else ""
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
new_summary = generate_summary(exchanges, started_at)
|
|
351
|
+
|
|
352
|
+
# Replace existing summary section
|
|
353
|
+
summary_idx = content.find(SUMMARY_MARKER)
|
|
354
|
+
if summary_idx < 0:
|
|
355
|
+
dialogue_path.write_text(
|
|
356
|
+
content + "\n" + new_summary, encoding="utf-8"
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
before = content[:summary_idx]
|
|
360
|
+
dialogue_path.write_text(before + new_summary, encoding="utf-8")
|
|
361
|
+
|
|
362
|
+
return DialogueResult(
|
|
363
|
+
success=True, data={"totalExchanges": len(exchanges)}
|
|
364
|
+
)
|
|
365
|
+
except Exception as err:
|
|
366
|
+
return DialogueResult(
|
|
367
|
+
success=False, error=f"Failed to refresh summary: {err}"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def archive_dialogue(
|
|
372
|
+
dialogue_path: Path,
|
|
373
|
+
archive_dir: Path,
|
|
374
|
+
jira_key: str | None = None,
|
|
375
|
+
story_id: str | None = None,
|
|
376
|
+
) -> DialogueResult:
|
|
377
|
+
"""Copy dialogue file to archive directory."""
|
|
378
|
+
try:
|
|
379
|
+
if not dialogue_path.exists():
|
|
380
|
+
return DialogueResult(
|
|
381
|
+
success=False,
|
|
382
|
+
error=f"Dialogue file not found: {dialogue_path}",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
|
|
387
|
+
prefix = jira_key or story_id or "unknown"
|
|
388
|
+
archive_name = f"{prefix}-dialogue.md"
|
|
389
|
+
archive_path = archive_dir / archive_name
|
|
390
|
+
|
|
391
|
+
shutil.copy2(dialogue_path, archive_path)
|
|
392
|
+
|
|
393
|
+
return DialogueResult(
|
|
394
|
+
success=True, data={"archivePath": str(archive_path)}
|
|
395
|
+
)
|
|
396
|
+
except Exception as err:
|
|
397
|
+
return DialogueResult(
|
|
398
|
+
success=False, error=f"Failed to archive dialogue: {err}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# =============================================================================
|
|
403
|
+
# Internal Helpers
|
|
404
|
+
# =============================================================================
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _parse_time(timestamp: str) -> int | None:
|
|
408
|
+
"""Parse HH:MM timestamp to minutes since midnight."""
|
|
409
|
+
parts = timestamp.split(":")
|
|
410
|
+
if len(parts) != 2:
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
hours = int(parts[0])
|
|
414
|
+
minutes = int(parts[1])
|
|
415
|
+
except ValueError:
|
|
416
|
+
return None
|
|
417
|
+
return hours * 60 + minutes
|
|
@@ -269,7 +269,7 @@ def detect_cyclist(project_dir: str | None = None) -> bool:
|
|
|
269
269
|
|
|
270
270
|
Checks:
|
|
271
271
|
1. CYCLIST env var set to '1' (Electron mode - definitive)
|
|
272
|
-
2. .
|
|
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)
|