relay-flow 0.2.3-alpha → 0.2.4-alpha

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 (55) hide show
  1. package/README.md +116 -10
  2. package/cmd/relay-flow/main.go +2 -0
  3. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  4. package/cmd/relay-flow/serve.go +2 -0
  5. package/examples/beads-workflow.yaml +3 -3
  6. package/examples/config-reference.yaml +144 -0
  7. package/examples/minimal-beads-task-workflow.yaml +34 -0
  8. package/examples/minimal-jira-task-workflow.yaml +68 -0
  9. package/examples/workflow-reference.yaml +111 -0
  10. package/internal/harness/opencode/opencode_test.go +1 -1
  11. package/internal/harness/opencode/repo_setup.go +1 -1
  12. package/internal/harness/pi/config_test.go +46 -0
  13. package/internal/harness/pi/lifecycle_test.go +69 -0
  14. package/internal/harness/pi/pi.go +261 -0
  15. package/internal/harness/pi/pi_test.go +322 -0
  16. package/internal/harness/pi/prompt_test.go +121 -0
  17. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  18. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  19. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  20. package/internal/harness/pi/validation_test.go +168 -0
  21. package/internal/runner/herdr/baseref.go +60 -0
  22. package/internal/runner/herdr/herdr.go +578 -0
  23. package/internal/runner/herdr/herdr_test.go +688 -0
  24. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  25. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  26. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  27. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  28. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  29. package/internal/runner/herdr/herdrcli/response.go +116 -0
  30. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  31. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  32. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  33. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  34. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  35. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  36. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  37. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  38. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  39. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  40. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  41. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  42. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  43. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  44. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  45. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  46. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  47. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  48. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  49. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  50. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  51. package/internal/task/beads/beads.go +0 -16
  52. package/internal/task/beads/config_compatibility_test.go +7 -8
  53. package/internal/task/jira/filters_test.go +32 -6
  54. package/internal/task/jira/jira.go +27 -17
  55. package/package.json +1 -1
@@ -0,0 +1,121 @@
1
+ package pi
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ "github.com/rajpopat27/relay-flow/internal/harness"
9
+ "github.com/rajpopat27/relay-flow/internal/workflow"
10
+ )
11
+
12
+ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
13
+ h := newPiHarness(t)
14
+ data := harness.PromptData{
15
+ TaskSystem: "jira",
16
+ Ticket: "PAY-101",
17
+ Workflow: "basicFlow",
18
+ Repo: "payments",
19
+ Node: "implement",
20
+ NodeType: workflow.NodeAgent,
21
+ Agent: "default",
22
+ NodeDescription: "Implement the requested change.",
23
+ NextSteps: "review (when: ready)",
24
+ Mailbox: "PAY-234",
25
+ }
26
+ nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
27
+
28
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
29
+ if err != nil {
30
+ t.Fatalf("RenderPrompt(initial): %v", err)
31
+ }
32
+ wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
33
+ if initial != wantInitial {
34
+ t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
35
+ }
36
+
37
+ feedback, err := h.RenderPrompt(harness.PromptFeedback, data, nudge)
38
+ if err != nil {
39
+ t.Fatalf("RenderPrompt(feedback): %v", err)
40
+ }
41
+ wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
42
+ if feedback != wantFeedback {
43
+ t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
44
+ }
45
+ }
46
+
47
+ func TestPiRenderPromptHonorsNodeNudgeTimingInput(t *testing.T) {
48
+ h, err := harness.New("pi", config.RawValues{
49
+ "initial": "initial {{node}}",
50
+ "feedback": "feedback {{node}}",
51
+ })
52
+ if err != nil {
53
+ t.Fatalf("harness.New(pi): %v", err)
54
+ }
55
+ data := harness.PromptData{Node: "implement", Agent: "default"}
56
+
57
+ for _, tt := range []struct {
58
+ name string
59
+ kind harness.PromptKind
60
+ want string
61
+ nudge string
62
+ }{
63
+ {name: "initial without nudge", kind: harness.PromptInitial, want: "initial implement"},
64
+ {name: "initial with nudge", kind: harness.PromptInitial, want: "initial implement\n\nnudge implement", nudge: "nudge {{node}}"},
65
+ {name: "feedback without nudge", kind: harness.PromptFeedback, want: "feedback implement"},
66
+ {name: "feedback with nudge", kind: harness.PromptFeedback, want: "feedback implement\n\nnudge implement", nudge: "nudge {{node}}"},
67
+ } {
68
+ t.Run(tt.name, func(t *testing.T) {
69
+ got, err := h.RenderPrompt(tt.kind, data, tt.nudge)
70
+ if err != nil {
71
+ t.Fatalf("RenderPrompt: %v", err)
72
+ }
73
+ if got != tt.want {
74
+ t.Fatalf("prompt = %q, want %q", got, tt.want)
75
+ }
76
+ })
77
+ }
78
+ }
79
+
80
+ func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
81
+ h, err := harness.New("pi", config.RawValues{
82
+ "initial": "agent={{agent}} node={{node}}",
83
+ "feedback": "feedback {{agent}}",
84
+ })
85
+ if err != nil {
86
+ t.Fatalf("harness.New(pi): %v", err)
87
+ }
88
+ got, err := h.RenderPrompt(harness.PromptInitial, harness.PromptData{
89
+ Agent: "default",
90
+ Node: "implement",
91
+ }, "")
92
+ if err != nil {
93
+ t.Fatalf("RenderPrompt: %v", err)
94
+ }
95
+ if got != "agent=default node=implement" {
96
+ t.Fatalf("prompt = %q, want default agent label", got)
97
+ }
98
+ }
99
+
100
+ func TestPiPromptsDoNotIncludeOpenCodeQuestionInstructions(t *testing.T) {
101
+ h := newPiHarness(t)
102
+ data := harness.PromptData{
103
+ TaskSystem: "jira",
104
+ Ticket: "PAY-101",
105
+ Node: "review",
106
+ NodeType: workflow.NodeHITL,
107
+ Agent: "default",
108
+ Mailbox: "PAY-235",
109
+ }
110
+ for _, kind := range []harness.PromptKind{harness.PromptInitial, harness.PromptFeedback} {
111
+ got, err := h.RenderPrompt(kind, data, "")
112
+ if err != nil {
113
+ t.Fatalf("RenderPrompt(%q): %v", kind, err)
114
+ }
115
+ for _, forbidden := range []string{"OpenCode", "Question tool", "Approve", "Reject"} {
116
+ if strings.Contains(got, forbidden) {
117
+ t.Fatalf("%q prompt contains OpenCode HITL instruction %q: %q", kind, forbidden, got)
118
+ }
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,126 @@
1
+ {
2
+ "capturedAt": "2026-08-31T18:34Z",
3
+ "version": "0.84.1",
4
+ "command": "/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/0.84.1/bin/pi",
5
+ "cwd": "/tmp/pi-live-contract/project",
6
+ "note": "Executed live against installed Pi 0.84.1 with a real provider (github-copilot). Interactive runs used script(1) to supply a PTY. The smoke runs added --session-dir, --model, --thinking, -nt and -e for isolation/observability; the production launch shape itself is argv[0..] below and was validated by the rejection cases in errors.",
7
+ "argv": {
8
+ "production": {
9
+ "fresh": [
10
+ "pi",
11
+ "--name",
12
+ "PAY-101:implement",
13
+ "<rendered prompt as one positional argument>"
14
+ ],
15
+ "resume": [
16
+ "pi",
17
+ "--name",
18
+ "PAY-101:implement",
19
+ "--session-id",
20
+ "<persisted session id>",
21
+ "<rendered prompt as one positional argument>"
22
+ ]
23
+ },
24
+ "smokeObserved": [
25
+ "/home/linuxbrew/.linuxbrew/Cellar/node/26.7.0/bin/node",
26
+ "/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/0.84.1/libexec/bin/pi",
27
+ "--name",
28
+ "PAY-101:implement",
29
+ "--session-dir",
30
+ "/tmp/pi-live-contract/sessions",
31
+ "--model",
32
+ "github-copilot/claude-haiku-4.5",
33
+ "--thinking",
34
+ "off",
35
+ "-nt",
36
+ "-e",
37
+ "<extension path>",
38
+ "<prompt>"
39
+ ]
40
+ },
41
+ "interactive": {
42
+ "stdinIsTTY": true,
43
+ "stdoutIsTTY": true,
44
+ "mode": "tui",
45
+ "hasUI": true,
46
+ "processAliveAfterSettled": true,
47
+ "observedAliveSecondsAfterSettle": 65,
48
+ "terminalTitle": "\u03c0 - PAY-101:review - project",
49
+ "footer": "/tmp/pi-live-contract/project \u2022 PAY-101:review"
50
+ },
51
+ "nonInteractive": {
52
+ "stdinIsTTY": false,
53
+ "stdoutIsTTY": false,
54
+ "mode": "print",
55
+ "hasUI": false,
56
+ "exitCode": 0,
57
+ "processExitsAfterResponse": true
58
+ },
59
+ "environment": {
60
+ "childCwd": "/tmp/pi-live-contract/project",
61
+ "relayFlowVariablesVisibleToExtension": [
62
+ "RELAY_FLOW_HOME",
63
+ "RELAY_FLOW_RUN_ID",
64
+ "RELAY_FLOW_WORKFLOW",
65
+ "RELAY_FLOW_REPO",
66
+ "RELAY_FLOW_TICKET",
67
+ "RELAY_FLOW_NODE",
68
+ "RELAY_FLOW_NODE_TYPE",
69
+ "RELAY_FLOW_NUDGE_PROMPT",
70
+ "RELAY_FLOW_NEXT_STEPS_JSON"
71
+ ]
72
+ },
73
+ "session": {
74
+ "fresh": {
75
+ "id": "01a0591c-3747-7075-9bb7-cc89a0f65dcd",
76
+ "file": "/tmp/pi-live-contract/sessions/2026-08-31T18-36-56-519Z_01a0591c-3747-7075-9bb7-cc89a0f65dcd.jsonl",
77
+ "branchLengthAtSessionStart": 3
78
+ },
79
+ "resumed": {
80
+ "requestedSessionId": "01a0591b-51a6-7cf0-8a6d-e9b31594cca4",
81
+ "observedSessionId": "01a0591b-51a6-7cf0-8a6d-e9b31594cca4",
82
+ "file": "/tmp/pi-live-contract/sessions/2026-08-31T18-35-57-734Z_01a0591b-51a6-7cf0-8a6d-e9b31594cca4.jsonl",
83
+ "branchLengthAtSessionStart": 6,
84
+ "priorEntryIdsPreserved": true,
85
+ "modelRecalledPriorTurn": "I output STATUS: success."
86
+ },
87
+ "nameSource": "--name; pi.getSessionName() returns it at session_start, so the extension's rename is a no-op",
88
+ "entryShape": "SessionEntry { type, id, parentId, timestamp, message }; branch also carries non-message entries session_info, model_change, thinking_level_change; assistant message has role/content[{type:text}]/stopReason=stop"
89
+ },
90
+ "extension": {
91
+ "events": [
92
+ "session_start",
93
+ "agent_settled"
94
+ ],
95
+ "sessionStartEventShape": [
96
+ "type",
97
+ "reason"
98
+ ],
99
+ "agentSettledEventShape": [
100
+ "type"
101
+ ],
102
+ "sessionStartContext": {
103
+ "mode": "tui",
104
+ "hasUI": true,
105
+ "cwd": "/tmp/pi-live-contract/project",
106
+ "uiSelectType": "function"
107
+ },
108
+ "actionsExercisedLive": [
109
+ "pi.getSessionName",
110
+ "pi.sendUserMessage",
111
+ "ctx.sessionManager.getSessionId",
112
+ "ctx.sessionManager.getSessionFile",
113
+ "ctx.sessionManager.getBranch",
114
+ "ctx.ui.select"
115
+ ],
116
+ "ui": "ctx.ui.select(title, options) -> Promise<string | undefined>; live selector rendered 'Approve relay-flow report for PAY-101:review' with Approve/Reject",
117
+ "hasUIIsTheRealGuard": "ctx.ui.select remains typeof 'function' in print mode while ctx.hasUI is false"
118
+ },
119
+ "errors": {
120
+ "bareDoubleDash": "Error: Unknown option: --",
121
+ "agentFlag": "Error: Unknown option: --agent",
122
+ "interactiveFlag": "Error: Unknown option: --interactive",
123
+ "reportFlag": "Error: Unknown option: --report",
124
+ "agentList": "No named-agent listing command; pi --list-models lists models only"
125
+ }
126
+ }
@@ -0,0 +1,11 @@
1
+ Live non-TTY run of installed Pi 0.84.1 (stdin and stdout redirected, real provider):
2
+
3
+ $ pi --name "PAY-101:implement" --session-dir <dir> --model github-copilot/claude-haiku-4.5 \
4
+ --thinking off -nt -e <capture extension> "Reply with exactly: PRINT MODE OK" < /dev/null > out.txt
5
+ exit=0
6
+ stdout: PRINT MODE OK
7
+
8
+ Extension context observed in the same run: mode=print, hasUI=false, stdinIsTTY=false,
9
+ stdoutIsTTY=false, and the process exited after the single response. This is why the
10
+ relay-flow launch path requires the runner's PTY: without both TTY streams Pi selects
11
+ print mode and cannot host an interactive HITL session.
@@ -0,0 +1,17 @@
1
+ Sanitized excerpts from the live Pi 0.84.1 TUI run (PTY supplied by script(1)); ANSI
2
+ sequences stripped, repeated spinner frames removed.
3
+
4
+ π - PAY-101:review - project <- terminal title from the session name
5
+ /tmp/pi-live-contract/project • PAY-101:review <- footer: cwd • session name (--name)
6
+ ↑641 ↓107 $0.001 (sub) 0.4%/200k (auto) claude-haiku-4.5 • thinking off
7
+
8
+ STATUS: success
9
+ ...
10
+ VERIFICATION: Ran the live Pi smoke test
11
+
12
+ Approve relay-flow report for PAY-101:review <- ctx.ui.select title from plugin/pi.ts
13
+ Approve
14
+ Reject
15
+
16
+ The process stayed alive after the response settled and after the selector was dismissed
17
+ with Escape; it was terminated by the smoke test, not by Pi.
@@ -0,0 +1,168 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ func TestPiValidateAgentAcceptsDefaultWithPiOnPATH(t *testing.T) {
12
+ fakeDir := strictPiAvailabilityCLI(t)
13
+ t.Setenv("PATH", fakeDir)
14
+
15
+ h := newPiHarness(t)
16
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", "default"); err != nil {
17
+ t.Fatalf("default agent rejected when pi is available: %v", err)
18
+ }
19
+ }
20
+
21
+ func TestPiValidateAgentRejectsUnavailablePi(t *testing.T) {
22
+ directory := t.TempDir()
23
+ // Keep PATH controlled and empty of both the fake and any host-installed Pi.
24
+ t.Setenv("PATH", directory)
25
+
26
+ h := newPiHarness(t)
27
+ err := h.ValidateAgent(context.Background(), "/srv/payments", "default")
28
+ if err == nil {
29
+ t.Fatal("default agent accepted when pi is unavailable")
30
+ }
31
+ if !strings.Contains(err.Error(), "pi") {
32
+ t.Fatalf("error = %q, want Pi availability context", err)
33
+ }
34
+ }
35
+
36
+ func TestPiValidateAgentAcceptsExistingRoleAndPiOnPath(t *testing.T) {
37
+ fakeDir := strictPiAvailabilityCLI(t)
38
+ t.Setenv("PATH", fakeDir)
39
+ repoPath := t.TempDir()
40
+ rolePath := writePiRole(t, repoPath, "coder", "You are the coder.\n")
41
+
42
+ h := newPiHarness(t)
43
+ if err := h.ValidateAgent(context.Background(), repoPath, "coder"); err != nil {
44
+ t.Fatalf("existing role %q rejected: %v", rolePath, err)
45
+ }
46
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
47
+ t.Fatalf("role validation unexpectedly executed Pi: %v", calls)
48
+ }
49
+ }
50
+
51
+ func TestPiValidateAgentRejectsMissingRoleBeforePiLookup(t *testing.T) {
52
+ fakeDir := strictPiAvailabilityCLI(t)
53
+ t.Setenv("PATH", fakeDir)
54
+
55
+ h := newPiHarness(t)
56
+ err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer")
57
+ if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
58
+ t.Fatalf("missing role error = %v, want role path", err)
59
+ }
60
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
61
+ t.Fatalf("missing role invoked Pi lookup: %v", calls)
62
+ }
63
+ }
64
+
65
+ func TestPiValidateAgentRejectsUnsafeRolePath(t *testing.T) {
66
+ fakeDir := strictPiAvailabilityCLI(t)
67
+ t.Setenv("PATH", fakeDir)
68
+
69
+ h := newPiHarness(t)
70
+ for _, role := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
71
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), role); err == nil {
72
+ t.Errorf("unsafe role %q accepted", role)
73
+ }
74
+ }
75
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
76
+ t.Fatalf("unsafe roles invoked Pi lookup: %v", calls)
77
+ }
78
+ }
79
+
80
+ func TestPiValidateAgentRejectsEmptyAndNonRegularRoleFiles(t *testing.T) {
81
+ fakeDir := strictPiAvailabilityCLI(t)
82
+ t.Setenv("PATH", fakeDir)
83
+
84
+ emptyRepo := t.TempDir()
85
+ emptyPath := filepath.Join(emptyRepo, ".pi", "roles", "coder.md")
86
+ if err := os.MkdirAll(filepath.Dir(emptyPath), 0o755); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+
93
+ directoryRepo := t.TempDir()
94
+ directoryPath := filepath.Join(directoryRepo, ".pi", "roles", "reviewer.md")
95
+ if err := os.MkdirAll(directoryPath, 0o755); err != nil {
96
+ t.Fatal(err)
97
+ }
98
+
99
+ h := newPiHarness(t)
100
+ if err := h.ValidateAgent(context.Background(), emptyRepo, "coder"); err == nil || !strings.Contains(err.Error(), "empty") {
101
+ t.Fatalf("empty role error = %v, want empty-role error", err)
102
+ }
103
+ if err := h.ValidateAgent(context.Background(), directoryRepo, "reviewer"); err == nil || !strings.Contains(err.Error(), "regular file") {
104
+ t.Fatalf("directory role error = %v, want non-regular-role error", err)
105
+ }
106
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
107
+ t.Fatalf("invalid role files invoked Pi lookup: %v", calls)
108
+ }
109
+ }
110
+
111
+ func TestPiValidateAgentRejectsUnsupportedLabelsWithoutAgentDiscovery(t *testing.T) {
112
+ fakeDir := strictPiAvailabilityCLI(t)
113
+ t.Setenv("PATH", fakeDir)
114
+
115
+ h := newPiHarness(t)
116
+ for _, label := range []string{"build", "plan", "gpt-4o", ""} {
117
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", label); err == nil {
118
+ t.Errorf("unsupported agent label %q accepted", label)
119
+ }
120
+ }
121
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
122
+ t.Fatalf("unsupported labels invoked pi instead of rejecting directly: %v", calls)
123
+ }
124
+ }
125
+
126
+ func strictPiAvailabilityCLI(t *testing.T) string {
127
+ t.Helper()
128
+ directory := t.TempDir()
129
+ executable := filepath.Join(directory, "pi")
130
+ calls := filepath.Join(directory, "calls")
131
+ script := `#!/bin/sh
132
+ set -eu
133
+ printf '%s\000' "$*" >> "${PI_FAKE_CALLS:?}"
134
+ case "$#:$*" in
135
+ 1:--version) printf '0.84.1\n' ;;
136
+ *) exit 2 ;;
137
+ esac
138
+ `
139
+ if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ t.Setenv("PI_FAKE_CALLS", calls)
143
+ return directory
144
+ }
145
+
146
+ func readAvailabilityCalls(t *testing.T, directory string) []string {
147
+ t.Helper()
148
+ data, err := os.ReadFile(filepath.Join(directory, "calls"))
149
+ if os.IsNotExist(err) {
150
+ return nil
151
+ }
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ return strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")
156
+ }
157
+
158
+ func writePiRole(t *testing.T, repoPath, name, content string) string {
159
+ t.Helper()
160
+ path := filepath.Join(repoPath, ".pi", "roles", name+".md")
161
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
162
+ t.Fatal(err)
163
+ }
164
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
165
+ t.Fatal(err)
166
+ }
167
+ return path
168
+ }
@@ -0,0 +1,60 @@
1
+ package herdr
2
+
3
+ import (
4
+ "fmt"
5
+ "os/exec"
6
+ "strings"
7
+ )
8
+
9
+ // originBaseRef resolves the branch new ticket worktrees are created from.
10
+ // The base is always the repository's origin branch; the ladder is
11
+ // origin/HEAD, then origin/main, then origin/master. A repository without an
12
+ // origin remote falls back to its local main or master.
13
+ //
14
+ // The base only applies when the ticket branch does not exist yet: Herdr
15
+ // checks out an existing branch as-is, so prior agent commits are preserved.
16
+ func originBaseRef(repoPath string) (string, error) {
17
+ if ref, ok := gitValue(repoPath, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"); ok && ref != "" {
18
+ return ref, nil
19
+ }
20
+ for _, ref := range []string{"origin/main", "origin/master"} {
21
+ if gitHasRef(repoPath, "refs/remotes/"+ref) {
22
+ return ref, nil
23
+ }
24
+ }
25
+ if !gitHasOrigin(repoPath) {
26
+ for _, ref := range []string{"main", "master"} {
27
+ if gitHasRef(repoPath, "refs/heads/"+ref) {
28
+ return ref, nil
29
+ }
30
+ }
31
+ }
32
+ return "", fmt.Errorf("herdr: repository %q has no origin/HEAD, origin/main, origin/master, or local main/master to branch from; set it with: git -C %s remote set-head origin -a", repoPath, repoPath)
33
+ }
34
+
35
+ func gitValue(repoPath string, args ...string) (string, bool) {
36
+ cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
37
+ out, err := cmd.Output()
38
+ if err != nil {
39
+ return "", false
40
+ }
41
+ return strings.TrimSpace(string(out)), true
42
+ }
43
+
44
+ func gitHasRef(repoPath, ref string) bool {
45
+ _, ok := gitValue(repoPath, "rev-parse", "--verify", "--quiet", ref)
46
+ return ok
47
+ }
48
+
49
+ func gitHasOrigin(repoPath string) bool {
50
+ out, ok := gitValue(repoPath, "remote")
51
+ if !ok {
52
+ return false
53
+ }
54
+ for _, remote := range strings.Fields(out) {
55
+ if remote == "origin" {
56
+ return true
57
+ }
58
+ }
59
+ return false
60
+ }