relay-flow 0.2.6-alpha → 0.2.8-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.
package/README.md CHANGED
@@ -1,36 +1,160 @@
1
1
  # relay-flow
2
2
 
3
- Durable, graph-based agent workflow runner. A ticket is the unit of work; a workflow YAML declares nodes and routes; a durable engine (go-workflows + SQLite) drives progression, waits, retries, and recovery. The task system (Jira and Beads built-ins) supplies parent tickets and mailbox subtasks; the runner (Orca built-in) owns worktrees and terminals; the harness (OpenCode and Pi built-ins) owns agent sessions and report semantics. All three are pluggable.
3
+ Durable, graph-based agent workflow runner. A ticket is the unit of work; a workflow YAML declares nodes and routes; a durable executor drives progression, waits, retries, and recovery. Task systems (Jira and Beads), runners (Orca and Herdr), harnesses (OpenCode and Pi), and durable executors (go-workflows and Temporal) are selectable independently.
4
4
 
5
5
  This is a ground-up rewrite. The previous per-workflow, in-memory daemon is gone. There is no migration path and no compatibility layer.
6
6
 
7
7
  ---
8
8
 
9
- ## Setup
9
+ ## Quick start
10
10
 
11
- ### Prerequisites
11
+ ### 1. Install relay-flow
12
12
 
13
- | Tool | Why |
14
- |---|---|
15
- | [opencode](https://opencode.ai) | Agents run in opencode sessions (harness) |
16
- | [Orca](https://github.com/Necmttn/orca) CLI + app | Worktrees + terminals (runner) |
17
- | `bd` CLI | Beads task-system access (required when `taskPlugin: beads`) |
18
- | Dolt | External/server-backed Beads only |
19
- | Jira API token | Jira REST API v3 access (required when `taskPlugin: jira`) |
20
- | Go 1.24+ | Build the CLI |
13
+ Install the latest released CLI with Homebrew:
21
14
 
22
- ### Install
15
+ ```sh
16
+ brew install rajpopat27/tap/relay-flow
17
+ ```
18
+
19
+ ### 2. Install the harness extension
20
+
21
+ Choose the harness that will run your agent sessions. Both commands install the
22
+ same `relay-flow-plugin` package with host-specific entrypoints.
23
+
24
+ **OpenCode**
25
+
26
+ ```sh
27
+ opencode plugin relay-flow-plugin@0.2.8-alpha
28
+ ```
29
+
30
+ **Pi**
31
+
32
+ ```sh
33
+ pi install npm:relay-flow-plugin@0.2.8-alpha
34
+ ```
35
+
36
+ Pi loads the package's `pi.ts` extension from its manifest. Do not add
37
+ `-e`/`--extension` to relay-flow's Pi launch command.
38
+
39
+ ### 3. Initialize relay-flow
40
+
41
+ Choose one task system, runner, harness, and durable executor. This example
42
+ uses Jira, Orca, OpenCode, and the default embedded executor:
43
+
44
+ ```sh
45
+ relay-flow init \
46
+ --task-plugin jira \
47
+ --runner-plugin orca \
48
+ --harness-plugin opencode
49
+ ```
50
+
51
+ The default executor is `goworkflows` with SQLite. To use Temporal instead,
52
+ add `--executor-plugin temporal`, `--temporal-address <host:port>`, and
53
+ `--temporal-namespace <name>` to the command.
54
+
55
+ ### 4. Authenticate the task system
56
+
57
+ For Jira, authenticate the selected task plugin:
58
+
59
+ ```sh
60
+ relay-flow task auth
61
+ ```
62
+
63
+ For Beads, skip this command and initialize/authenticate the Beads workspace
64
+ with `bd` and, when needed, Dolt. See [Beads task system](#beads-task-system)
65
+ below.
66
+
67
+ ### 5. Start the server
68
+
69
+ ```sh
70
+ relay-flow serve --background
71
+ ```
72
+
73
+ ### 6. Register a repository
74
+
75
+ The repository must already exist in the selected runner. For Orca:
76
+
77
+ ```sh
78
+ orca repo add --path /work/payments
79
+ relay-flow repo register
80
+ ```
81
+
82
+ For Herdr, register the repository path directly; relay-flow creates ticket
83
+ worktrees lazily. The interactive registration asks for the task-system values
84
+ required by the selected task plugin.
85
+
86
+ ### 7. Submit a workflow
87
+
88
+ ```sh
89
+ relay-flow workflow submit --file examples/minimal-jira-task-workflow.yaml
90
+ relay-flow workflow list
91
+ ```
92
+
93
+ Replace the example workflow with
94
+ `examples/minimal-beads-task-workflow.yaml` when using Beads. The workflow's
95
+ `repos` value must match the name used during repository registration.
96
+
97
+ ## Supported plugins
98
+
99
+ Each category is a replaceable boundary. Select one plugin from each category
100
+ at initialization; the workflow YAML and core orchestration do not change when
101
+ you switch an implementation.
102
+
103
+ | Category | Supported plugins | Owns |
104
+ |---|---|---|
105
+ | Task system | `jira`, `beads` | Parent tickets, mailbox subtasks, task state, labels, comments, and task configuration |
106
+ | Runner | `orca`, `herdr` | Ticket worktrees, environments, terminals, process liveness, and cleanup |
107
+ | Harness | `opencode`, `pi` | Agent launch commands, sessions, prompts, report parsing, nudges, and resume behavior |
108
+ | Durable executor | `goworkflows`, `temporal` | Graph progression, waits, retries, recovery, and durable execution state |
109
+
110
+ The default durable executor is `goworkflows`, which stores execution state in
111
+ SQLite. `temporal` uses an external Temporal server and is selected with the
112
+ Temporal address and namespace during `init`.
113
+
114
+ The plugin composition is explicit:
115
+
116
+ ```text
117
+ Task system (Jira or Beads)
118
+
119
+
120
+ Durable executor (go-workflows or Temporal)
121
+
122
+
123
+ Runner (Orca or Herdr)
124
+
125
+
126
+ Harness (OpenCode or Pi)
127
+
128
+
129
+ relay-flow report transport
130
+ ```
131
+
132
+ The equivalent non-interactive selection is:
23
133
 
24
134
  ```sh
25
- go install github.com/rajpopat27/relay-flow/cmd/relay-flow@latest
135
+ relay-flow init \
136
+ --task-plugin <jira|beads> \
137
+ --runner-plugin <orca|herdr> \
138
+ --harness-plugin <opencode|pi> \
139
+ --executor-plugin <goworkflows|temporal>
26
140
  ```
27
141
 
142
+ `--executor-plugin` defaults to `goworkflows`. Jira requires Jira credentials;
143
+ Beads requires the `bd` CLI and a configured workspace. Orca requires its CLI
144
+ and app; Herdr requires its CLI/server. OpenCode and Pi each require their
145
+ corresponding agent runtime. These integrations remain behind their small
146
+ contracts, so task-system fields do not leak into runners or harnesses.
147
+
148
+ ## Detailed setup
149
+
150
+ ### Harness configuration
151
+
28
152
  OpenCode plugin configuration uses both entrypoints. The server entrypoint is listed in `opencode.json`:
29
153
 
30
154
  ```json
31
155
  {
32
156
  "$schema": "https://opencode.ai/config.json",
33
- "plugin": ["relay-flow-plugin"]
157
+ "plugin": ["relay-flow-plugin@0.2.8-alpha"]
34
158
  }
35
159
  ```
36
160
 
@@ -39,7 +163,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
39
163
  ```json
40
164
  {
41
165
  "$schema": "https://opencode.ai/tui.json",
42
- "plugin": ["relay-flow-plugin"]
166
+ "plugin": ["relay-flow-plugin@0.2.8-alpha"]
43
167
  }
44
168
  ```
45
169
 
@@ -55,7 +179,7 @@ Pi plugin: install the same published package manually in Pi's global package
55
179
  settings before starting a Pi harness session:
56
180
 
57
181
  ```sh
58
- pi install npm:relay-flow-plugin@<version>
182
+ pi install npm:relay-flow-plugin@0.2.8-alpha
59
183
  ```
60
184
 
61
185
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -71,14 +195,14 @@ structured report, applies the agent/HITL nudge policy, and delivers
71
195
  `reportId` comes from the harness session/message identity; `nodeVisitID` is
72
196
  internal and is never part of either plugin payload.
73
197
 
74
- ### One-time machine setup
198
+ ### Machine setup details
75
199
 
76
200
  ```sh
77
201
  relay-flow init
78
202
  relay-flow task auth
79
203
  ```
80
204
 
81
- `init` only selects the task system, runner, and harness (singleton options are automatic), writes machine config, and initializes SQLite. `task auth` delegates authentication to that selected task plug-in. Jira prompts for its site, email, and masked API token, validates `/myself`, and owns the system-wide `credentials.yaml`; for scripts, pass `task auth --site`, `--email`, and `--token`. A normal init rerun refuses existing state. `relay-flow init --force` updates safe stopped instances while preserving durable and repo state.
205
+ `init` selects the task system, runner, harness, and durable executor (singleton options are automatic), writes machine config, and initializes the selected execution backend. `task auth` delegates authentication to that selected task plug-in. Jira prompts for its site, email, and masked API token, validates `/myself`, and owns the system-wide `credentials.yaml`; for scripts, pass `task auth --site`, `--email`, and `--token`. A normal init rerun refuses existing state. `relay-flow init --force` updates safe stopped instances while preserving durable and repo state.
82
206
 
83
207
  For Beads, select the plugin explicitly when scripting setup:
84
208
 
@@ -131,11 +255,23 @@ orca repo add --path /work/payments # displayName becomes "payments"
131
255
  relay-flow repo register
132
256
  ```
133
257
 
134
- Shows a multi-select titled `Select repositories`; use Space to select Orca repos and Enter to confirm. Enter the Jira project once. Each repo is registered sequentially with its Orca name/path and a Jira component derived from that repo name. Earlier registrations remain if a later one fails.
258
+ Shows a multi-select titled `Select repositories`; use Space to select Orca repos and Enter to confirm. Enter the Jira project once, then choose the repository's start, work, and end statuses from the statuses discovered for that project. Each repo is registered sequentially with its Orca name/path and a Jira component derived from that repo name. Earlier registrations remain if a later one fails.
135
259
 
136
260
  Each Jira poll uses REST v3 enhanced search and requests linked-issue status with the candidate fields. Tickets with any unfinished inward `Blocks` issue are filtered before routing; no per-ticket blocker lookup is made.
137
261
 
138
- For scripts, use `relay-flow repo register --name <name> --path <path> --set project=<project>`. Component is always derived from `--name` and cannot be overridden. Registration is rejected while another repo already holds the same canonical task scope.
262
+ For scripted Jira registration, pass all three repository status defaults explicitly:
263
+
264
+ ```sh
265
+ relay-flow repo register \
266
+ --name payments \
267
+ --path /work/payments \
268
+ --set project=PAY \
269
+ --set statusDefaults.start="Open" \
270
+ --set statusDefaults.work="Working" \
271
+ --set statusDefaults.end="Closed"
272
+ ```
273
+
274
+ The values must be statuses available to the Jira project. Component is always derived from `--name` and cannot be overridden. Registration is rejected while another repo already holds the same canonical task scope.
139
275
 
140
276
  ### Beads task system
141
277
 
@@ -229,10 +365,7 @@ Workflows live at `~/.relay-flow/workflows/<name>.yaml` after submit. Replacemen
229
365
 
230
366
  Use [`examples/config-reference.yaml`](examples/config-reference.yaml) for the complete machine configuration, [`examples/workflow-reference.yaml`](examples/workflow-reference.yaml) for the complete workflow schema, or the provider-specific minimal workflows [`examples/minimal-jira-task-workflow.yaml`](examples/minimal-jira-task-workflow.yaml) and [`examples/minimal-beads-task-workflow.yaml`](examples/minimal-beads-task-workflow.yaml). Runtime node agents should follow [`docs/agent-instructions.md`](docs/agent-instructions.md). The existing [`examples/default-story-workflow.yaml`](examples/default-story-workflow.yaml) remains a more detailed Jira Story example, while [`examples/beads-workflow.yaml`](examples/beads-workflow.yaml) shows the Beads lifecycle shape. Replace the repo name and uncomment only the optional fields you need.
231
367
 
232
- Task, runner, and harness plugins are selected machine-wide. A single relay-flow
233
- configuration cannot run Jira and Beads simultaneously; use separate
234
- `RELAY_FLOW_HOME` directories or machine configurations when both providers are
235
- needed.
368
+ Task, runner, harness, and durable executor plugins are selected machine-wide. A single relay-flow configuration cannot run Jira and Beads simultaneously; use separate `RELAY_FLOW_HOME` directories or machine configurations when both providers are needed.
236
369
 
237
370
  ### Run
238
371
 
@@ -262,6 +395,8 @@ cleanupRunnerOnEnd: false # optional; when true the runner tears down at
262
395
  taskConfig: # optional; adapter-owned; merged root → repo → workflow → node
263
396
  filters:
264
397
  parentStatuses: [To Do]
398
+ labels: ["workflow:true"]
399
+ assignees: ["currentUser()"] # Jira resolves this to the authenticated email.
265
400
 
266
401
  nodes:
267
402
  start:
@@ -271,20 +406,43 @@ nodes:
271
406
  onSuccess: [{ target: coding }]
272
407
 
273
408
  coding:
274
- type: agent # or hitl
275
- agent: build # opencode agent
409
+ type: agent
410
+ agent: build
276
411
  description: | # becomes the mailbox description and launch prompt
277
- Implement the ticket.
412
+ Implement the ticket in the current worktree.
413
+ nudgePrompt: |
414
+ Continue working on {{ticket}}. Read the parent {{taskSystem}} ticket
415
+ {{ticket}} and your assigned ticket {{mailbox}} to understand the requirements. Read
416
+ the latest feedback, address the requested changes, and work on the next
417
+ bounded task slice. Return the complete report. Valid choices are:
418
+ {{nextSteps}}.
278
419
  onSuccess: [{ target: reviewing, when: "work complete" }]
279
420
  onFailure: [{ target: coding, when: "retry" }]
280
- nudgePrompt: "Check edge cases for {{ticket}} before reporting." # optional custom instructions
281
421
 
282
422
  reviewing:
423
+ type: agent
424
+ agent: plan
425
+ description: Review the completed implementation and report required changes.
426
+ nudgePrompt: |
427
+ Review {{ticket}}. Read the parent {{taskSystem}} ticket {{ticket}} and
428
+ your assigned ticket {{mailbox}} to understand the requirements. Re-check the
429
+ implementation and latest coding feedback, then return the complete
430
+ report. Valid choices are:
431
+ {{nextSteps}}.
432
+ onSuccess: [{ target: humanReview, when: "ready for human review" }]
433
+ onFailure: [{ target: coding, when: "changes required" }]
434
+
435
+ humanReview:
283
436
  type: hitl
284
- agent: build
285
- description: Human review.
286
- onSuccess: [{ target: end }]
287
- onFailure: [{ target: coding }]
437
+ agent: plan
438
+ description: Approve the reviewed implementation or request changes.
439
+ nudgePrompt: |
440
+ Review the completed work for {{ticket}} with the human. Read the parent
441
+ {{taskSystem}} ticket {{ticket}} and your assigned ticket {{mailbox}} to
442
+ understand the requirements. Return the complete report. Valid choices are:
443
+ {{nextSteps}}.
444
+ onSuccess: [{ target: end, when: "approved" }]
445
+ onFailure: [{ target: coding, when: "changes requested" }]
288
446
 
289
447
  end: {}
290
448
  ```
@@ -468,7 +626,7 @@ tickets are not reopened automatically.
468
626
  ## Architecture
469
627
 
470
628
  - **Task system** owns parent tickets, mailbox subtasks, task state, labels, comments, and adapter config. The parent ticket is the unit of work.
471
- - **Durable workflow engine** (go-workflows + SQLite) owns graph progression, waits, reports, retries, and recovery. No custom state machine.
629
+ - **Durable workflow engine** (`goworkflows` + SQLite or `temporal`) owns graph progression, waits, reports, retries, and recovery. No custom state machine.
472
630
  - **Mailbox subtask** is one agent/HITL node's scratch space; its description defines the node's work and its comments hold the node's summary plus selected incoming feedback.
473
631
  - **Harness** owns agent launch, session/report behavior, parsing, nudging, and resume semantics.
474
632
  - **Runner** owns ticket worktrees/environments, terminals, liveness, and execution of harness commands.
@@ -640,9 +640,13 @@ type registerServer struct {
640
640
  calls int
641
641
  }
642
642
 
643
- func (s *registerServer) TaskFields(context.Context) ([]string, error) {
643
+ func (s *registerServer) TaskRegistrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
644
644
  s.calls++
645
- return s.fields, nil
645
+ out := make([]task.RegistrationField, 0, len(s.fields))
646
+ for _, field := range s.fields {
647
+ out = append(out, task.RegistrationField{Key: field, Title: field, Derived: field == "component"})
648
+ }
649
+ return out, nil
646
650
  }
647
651
  func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput) (repo.Info, error) {
648
652
  s.calls++
@@ -767,7 +771,67 @@ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
767
771
  }
768
772
  }
769
773
 
770
- func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
774
+ func TestRegistrationSelectRequiresExplicitChoiceWithoutDefault(t *testing.T) {
775
+ field := task.RegistrationField{Key: "statusDefaults.start", Options: []string{"Open", "Working"}}
776
+ options := registrationSelectOptions(field)
777
+ if len(options) != 3 {
778
+ t.Fatalf("options = %d, want empty choice plus two statuses", len(options))
779
+ }
780
+ var selected string
781
+ selectField := huh.NewSelect[string]().Options(options...).Value(&selected)
782
+ var out bytes.Buffer
783
+ if err := selectField.RunAccessible(&out, strings.NewReader("1\n")); err != nil {
784
+ t.Fatal(err)
785
+ }
786
+ if selected != "" {
787
+ t.Fatalf("empty choice selected %q, want empty value", selected)
788
+ }
789
+ if err := selectField.RunAccessible(&out, strings.NewReader("2\n")); err != nil {
790
+ t.Fatal(err)
791
+ }
792
+ if selected != "Open" {
793
+ t.Fatalf("explicit status selection = %q, want Open", selected)
794
+ }
795
+ }
796
+
797
+ func TestRegistrationTaskConfigBuildsNestedStatusDefaults(t *testing.T) {
798
+ registration := task.Registration{Fields: []task.RegistrationField{
799
+ {Key: "project", Title: "Project"},
800
+ {Key: "component", Title: "Component", Derived: true},
801
+ {Key: "statusDefaults.start", Title: "Start", Options: []string{"Open", "Working", "Closed"}},
802
+ {Key: "statusDefaults.work", Title: "Work", Options: []string{"Open", "Working", "Closed"}},
803
+ {Key: "statusDefaults.end", Title: "End", Options: []string{"Open", "Working", "Closed"}},
804
+ }}
805
+ got, err := registrationTaskConfig(registration, kvFlags{
806
+ "project": "PAY",
807
+ "statusDefaults.start": "Open",
808
+ "statusDefaults.work": "Working",
809
+ "statusDefaults.end": "Closed",
810
+ }, "payments")
811
+ if err != nil {
812
+ t.Fatal(err)
813
+ }
814
+ defaults, ok := got["statusDefaults"].(map[string]any)
815
+ if !ok || defaults["start"] != "Open" || defaults["work"] != "Working" || defaults["end"] != "Closed" {
816
+ t.Fatalf("nested statusDefaults = %#v", got["statusDefaults"])
817
+ }
818
+ for _, tc := range []struct {
819
+ name string
820
+ sets kvFlags
821
+ want string
822
+ }{
823
+ {name: "invalid status", sets: kvFlags{"project": "PAY", "statusDefaults.start": "Missing"}, want: "not one of the available choices"},
824
+ {name: "derived component", sets: kvFlags{"project": "PAY", "component": "other"}, want: "derived"},
825
+ } {
826
+ t.Run(tc.name, func(t *testing.T) {
827
+ if _, err := registrationTaskConfig(registration, tc.sets, "payments"); err == nil || !strings.Contains(err.Error(), tc.want) {
828
+ t.Fatalf("registration error = %v, want %q", err, tc.want)
829
+ }
830
+ })
831
+ }
832
+ }
833
+
834
+ func TestRepoMultiSelectAndPluginMapping(t *testing.T) {
771
835
  selected := []int{0, 1}
772
836
  field := repoMultiSelect([]huh.Option[int]{
773
837
  huh.NewOption("payments", 0),
@@ -783,9 +847,10 @@ func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
783
847
  if fmt.Sprint(selected) != "[0 1]" {
784
848
  t.Fatalf("selected = %v, want [0 1]", selected)
785
849
  }
786
- if got := registrationSharedFields([]string{"project", "component"}); fmt.Sprint(got) != "[project]" {
787
- t.Fatalf("prompted fields = %v, want project only", got)
788
- }
850
+ registration := task.Registration{Fields: []task.RegistrationField{
851
+ {Key: "project", Title: "Project"},
852
+ {Key: "component", Title: "Component", Derived: true},
853
+ }}
789
854
 
790
855
  home := t.TempDir()
791
856
  deps := serveRegister(t, home, []string{"project", "component"})
@@ -794,8 +859,8 @@ func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
794
859
  {Name: "payments", Path: "/srv/payments"},
795
860
  {Name: "checkout", Path: "/srv/checkout"},
796
861
  }
797
- if err := registerSelectedRepos(context.Background(), client, candidates, selected,
798
- []string{"project", "component"}, kvFlags{"project": "PAY"}); err != nil {
862
+ if err := registerSelectedReposDynamic(context.Background(), client, candidates, selected,
863
+ registration, kvFlags{"project": "PAY"}); err != nil {
799
864
  t.Fatal(err)
800
865
  }
801
866
  if len(deps.successful) != 2 {
@@ -818,8 +883,12 @@ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
818
883
  {Name: "checkout", Path: "/srv/checkout"},
819
884
  {Name: "later", Path: "/srv/later"},
820
885
  }
821
- err := registerSelectedRepos(context.Background(), client, candidates, []int{0, 1, 2},
822
- []string{"project", "component"}, kvFlags{"project": "PAY"})
886
+ registration := task.Registration{Fields: []task.RegistrationField{
887
+ {Key: "project", Title: "Project"},
888
+ {Key: "component", Title: "Component", Derived: true},
889
+ }}
890
+ err := registerSelectedReposDynamic(context.Background(), client, candidates, []int{0, 1, 2},
891
+ registration, kvFlags{"project": "PAY"})
823
892
  if err == nil || !strings.Contains(err.Error(), "checkout") {
824
893
  t.Fatalf("partial failure = %v, want failed repo name", err)
825
894
  }
@@ -1012,7 +1081,9 @@ func (s *ackServer) CancelRun(context.Context, string, string) error { panic("un
1012
1081
  func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
1013
1082
  panic("unreachable")
1014
1083
  }
1015
- func (s *ackServer) TaskFields(context.Context) ([]string, error) { panic("unreachable") }
1084
+ func (s *ackServer) TaskRegistrationFields(context.Context, config.RawValues) ([]task.RegistrationField, error) {
1085
+ panic("unreachable")
1086
+ }
1016
1087
  func (s *ackServer) RegisterRepo(context.Context, repo.RegisterInput) (repo.Info, error) {
1017
1088
  panic("unreachable")
1018
1089
  }
@@ -798,7 +798,7 @@ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
798
798
  }
799
799
 
800
800
  // cmdRepoRegister registers one flagged repo or multiple interactively selected
801
- // runner repos. Jira project is shared and component is always the repo name.
801
+ // runner repos using registration metadata supplied by the selected task plugin.
802
802
  func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
803
803
  ctx := context.Background()
804
804
  flagged := flagName != "" || flagPath != "" || len(sets) > 0
@@ -809,16 +809,12 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
809
809
  fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
810
810
  return exitUsage
811
811
  }
812
- if _, ok := sets["component"]; ok {
813
- fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
814
- return exitUsage
815
- }
816
- fields, err := c.RepoTaskFields(ctx)
812
+ registration, err := loadRepoRegistration(ctx, c, sets)
817
813
  if err != nil {
818
814
  fmt.Fprintln(os.Stderr, err)
819
815
  return exitFail
820
816
  }
821
- taskCfg, err := repoTaskConfig(fields, sets, flagName)
817
+ taskCfg, err := registrationTaskConfig(registration, sets, flagName)
822
818
  if err != nil {
823
819
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
824
820
  return exitUsage
@@ -832,7 +828,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
832
828
  return exitOK
833
829
  }
834
830
 
835
- fields, err := c.RepoTaskFields(ctx)
831
+ registration, err := c.RepoRegistrationFields(ctx, nil)
836
832
  if err != nil {
837
833
  fmt.Fprintln(os.Stderr, err)
838
834
  return exitFail
@@ -864,27 +860,24 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
864
860
  return exitFail
865
861
  }
866
862
 
867
- sharedFields := registrationSharedFields(fields)
868
- values := make([]string, len(sharedFields))
869
- inputs := make([]huh.Field, len(sharedFields))
870
- for i, field := range sharedFields {
871
- title := field
872
- if field == "project" {
873
- title = "Jira project"
874
- }
875
- inputs[i] = huh.NewInput().Title(title).Value(&values[i])
876
- }
877
- if len(inputs) > 0 {
878
- if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
863
+ shared := kvFlags{}
864
+ if err := promptRepoRegistration(registration, shared); err != nil {
865
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
866
+ return exitFail
867
+ }
868
+ if len(shared) > 0 {
869
+ dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(shared))
870
+ if err != nil {
871
+ fmt.Fprintln(os.Stderr, err)
872
+ return exitFail
873
+ }
874
+ registration = mergeRegistration(registration, dependent)
875
+ if err := promptRepoRegistration(registration, shared); err != nil {
879
876
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
880
877
  return exitFail
881
878
  }
882
879
  }
883
- shared := kvFlags{}
884
- for i, field := range sharedFields {
885
- shared[field] = values[i]
886
- }
887
- if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
880
+ if err := registerSelectedReposDynamic(ctx, c, candidates, selected, registration, shared); err != nil {
888
881
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
889
882
  return exitFail
890
883
  }
@@ -904,67 +897,6 @@ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelec
904
897
  })
905
898
  }
906
899
 
907
- func registrationSharedFields(fields []string) []string {
908
- shared := make([]string, 0, len(fields))
909
- for _, field := range fields {
910
- if field != "component" {
911
- shared = append(shared, field)
912
- }
913
- }
914
- return shared
915
- }
916
-
917
- func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
918
- required := map[string]bool{}
919
- for _, field := range fields {
920
- required[field] = true
921
- }
922
- values := config.RawValues{}
923
- for key, value := range supplied {
924
- if !required[key] {
925
- return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
926
- }
927
- if value == "" {
928
- return nil, fmt.Errorf("task key %q requires a non-empty value", key)
929
- }
930
- values[key] = value
931
- }
932
- if required["component"] {
933
- values["component"] = repoName
934
- }
935
- var missing []string
936
- for _, field := range fields {
937
- if _, ok := values[field]; !ok {
938
- missing = append(missing, field)
939
- }
940
- }
941
- if len(missing) > 0 {
942
- return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
943
- strings.Join(missing, ", "), missing[0])
944
- }
945
- return values, nil
946
- }
947
-
948
- func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
949
- for _, index := range selected {
950
- candidate := candidates[index]
951
- taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
952
- if err != nil {
953
- return fmt.Errorf("%s: %w", candidate.Name, err)
954
- }
955
- info, err := c.RegisterRepo(ctx, repo.RegisterInput{
956
- Name: candidate.Name,
957
- Path: candidate.Path,
958
- TaskConfig: taskCfg,
959
- })
960
- if err != nil {
961
- return fmt.Errorf("%s: %w", candidate.Name, err)
962
- }
963
- fmt.Println(info.Name)
964
- }
965
- return nil
966
- }
967
-
968
900
  // kvFlags collects repeated key=value flags (registration task config).
969
901
  // Parse rejects malformed pairs, empty keys/values, and duplicates.
970
902
  type kvFlags map[string]string