relay-flow 0.3.10-alpha → 0.3.11-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
@@ -28,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
28
28
  **OpenCode**
29
29
 
30
30
  ```sh
31
- opencode plugin relay-flow-plugin@0.3.10-alpha
31
+ opencode plugin relay-flow-plugin@0.3.11-alpha
32
32
  ```
33
33
 
34
34
  **Pi**
35
35
 
36
36
  ```sh
37
- pi install npm:relay-flow-plugin@0.3.10-alpha
37
+ pi install npm:relay-flow-plugin@0.3.11-alpha
38
38
  ```
39
39
 
40
40
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -164,7 +164,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
164
164
  ```json
165
165
  {
166
166
  "$schema": "https://opencode.ai/config.json",
167
- "plugin": ["relay-flow-plugin@0.3.10-alpha"]
167
+ "plugin": ["relay-flow-plugin@0.3.11-alpha"]
168
168
  }
169
169
  ```
170
170
 
@@ -173,7 +173,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
173
173
  ```json
174
174
  {
175
175
  "$schema": "https://opencode.ai/tui.json",
176
- "plugin": ["relay-flow-plugin@0.3.10-alpha"]
176
+ "plugin": ["relay-flow-plugin@0.3.11-alpha"]
177
177
  }
178
178
  ```
179
179
 
@@ -189,7 +189,7 @@ Pi plugin: install the same published package manually in Pi's global package
189
189
  settings before starting a Pi harness session:
190
190
 
191
191
  ```sh
192
- pi install npm:relay-flow-plugin@0.3.10-alpha
192
+ pi install npm:relay-flow-plugin@0.3.11-alpha
193
193
  ```
194
194
 
195
195
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -614,28 +614,16 @@ durable run waiting; Pi does not require an LLM Question tool for this step.
614
614
 
615
615
  ## Structured node report
616
616
 
617
- Every visit (agent or HITL) submits one report with the same fixed labels:
617
+ Every visit (agent or HITL) submits one report with four fixed fields:
618
618
 
619
619
  ```
620
620
  STATUS: success | failure
621
- NEXT STEP: <one configured route for that status>
622
-
623
- SUMMARY:
624
- COMPLETED: ...
625
- COMMITS: <commit IDs or None>
626
- NOT COMPLETED: ... | None
627
- ISSUES DISCOVERED: ... | None
628
- VERIFICATION: ...
629
- NOTES: ... | None
630
-
631
- FEEDBACK:
632
- REASON FOR NEXT STEP: ...
633
- REQUIRED ACTIONS: ...
634
- RELEVANT CONTEXT: ...
635
- EXPECTED RESULT: ...
621
+ NEXT STEP: <one valid route>
622
+ SUMMARY: <concise result>
623
+ FEEDBACK: <concise handoff, or None when NEXT STEP is end>
636
624
  ```
637
625
 
638
- The labels above are fixed; configurable templates do not change the parsed report contract. The plugin submits one `report` object containing both lower-camel `summary` and `feedback` objects. Relay-flow validates that complete shape once, renders `summaryReport` through the task system's summary-comment template on the current mailbox, and renders `feedbackReport` through its feedback-comment template on only the selected next mailbox. `None` is the literal marker for an intentionally empty section. When `NEXT STEP` is `end`, every FEEDBACK field must be `None` and no feedback comment is written.
626
+ The report format is hardcoded, exposed as `{{report}}` in Jira mailbox templates and passed to the runtime plugin through `RELAY_FLOW_REPORT_FORMAT`; configurable templates do not change it. The plugin maps `SUMMARY` to `report.summary.completed` and `FEEDBACK` to `report.feedback.requiredActions`, filling the remaining internal JSON fields with `None`. Relay-flow validates the structured report and writes the current summary to its mailbox and only the selected feedback to the next mailbox. When `NEXT STEP` is `end`, `FEEDBACK` must be exactly `None` and no feedback comment is written.
639
627
 
640
628
  The plugin delivers `{runId, node, reportId, report}` as one JSON object via `relay-flow report` stdin with the shared backoff (initial 2s, factor 2, jitter 0.2, max 5m) until acknowledged. It derives `reportId` from the harness session/message identity. Duplicate/stale reports are acked safely with no repeated graph effects. Invalid agent output is nudged; ordinary, missing, or empty HITL output stays silent, while partial report-shaped HITL output is corrected and a valid HITL report opens the native TUI approval dialog. Relay-flow HITL approval does not use OpenCode's Question tool.
641
629
 
@@ -23,6 +23,7 @@ import (
23
23
 
24
24
  "github.com/charmbracelet/huh"
25
25
  "github.com/rajpopat27/relay-flow/internal/config"
26
+ "github.com/rajpopat27/relay-flow/internal/harness"
26
27
  "github.com/rajpopat27/relay-flow/internal/repo"
27
28
  runsvc "github.com/rajpopat27/relay-flow/internal/run"
28
29
  "github.com/rajpopat27/relay-flow/internal/runner"
@@ -386,6 +387,9 @@ func TestInitWritesHarnessPromptDefaults(t *testing.T) {
386
387
  t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
387
388
  }
388
389
  }
390
+ if cfg.HarnessConfig["initial"] != harness.JiraInitialPrompt || cfg.HarnessConfig["feedback"] != harness.JiraFeedbackPrompt {
391
+ t.Fatalf("Jira prompt defaults = %#v", cfg.HarnessConfig)
392
+ }
389
393
  }
390
394
 
391
395
  func TestInitWritesTaskTextDefaults(t *testing.T) {
@@ -406,6 +410,9 @@ func TestInitWritesTaskTextDefaults(t *testing.T) {
406
410
  t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
407
411
  }
408
412
  }
413
+ if !strings.Contains(templates["mailboxDescription"].(string), "{{report}}") {
414
+ t.Fatal("Jira mailbox description must expose the canonical report")
415
+ }
409
416
  }
410
417
 
411
418
  func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
@@ -480,7 +487,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
480
487
  if !ok {
481
488
  t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
482
489
  }
483
- taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
490
+ taskTemplates["mailboxDescription"] = "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}"
484
491
  if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
485
492
  t.Fatal(err)
486
493
  }
@@ -528,7 +535,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
528
535
  if !ok {
529
536
  t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
530
537
  }
531
- if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
538
+ if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}" {
532
539
  t.Fatalf("forced init changed task text override: %#v", got)
533
540
  }
534
541
  for _, key := range []string{"summaryComment", "feedbackComment"} {
@@ -664,6 +664,10 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
664
664
  fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
665
665
  return exitFail
666
666
  }
667
+ if names[0] == "jira" {
668
+ harnessDefaults["initial"] = harness.JiraInitialPrompt
669
+ harnessDefaults["feedback"] = harness.JiraFeedbackPrompt
670
+ }
667
671
  if !*force || !configExists {
668
672
  cfg.HarnessConfig = harnessDefaults
669
673
  } else {
@@ -42,21 +42,19 @@ taskConfig:
42
42
  # parentStatus: In Progress
43
43
  # taskStatus: In Progress
44
44
 
45
- # Task-system text templates. The fixed report contract is appended by
46
- # relay-flow and is not changed by these templates.
45
+ # Jira mailbox descriptions include the canonical report through {{report}}.
47
46
  templates:
48
47
  mailboxDescription: |-
49
- Parent ticket: {{ticket}}
50
- Workflow: {{workflow}}
51
- Node: {{node}}
52
- Node type: {{nodeType}}
53
- Agent: {{agent}}
54
- Mailbox: {{mailbox}}
55
-
56
- Node work:
48
+ {{ticket}} / {{node}} — {{nodeType}} — {{agent}}
57
49
  {{nodeDescription}}
58
50
 
59
- Read this mailbox's comments for feedback from previous nodes.
51
+ Success routes:
52
+ {{successRoutes}}
53
+
54
+ Failure routes:
55
+ {{failureRoutes}}
56
+
57
+ {{report}}
60
58
 
61
59
  summaryComment: |-
62
60
  Summary for {{node}}
@@ -80,14 +78,19 @@ harnessPlugin: opencode
80
78
  harnessConfig:
81
79
  # OpenCode supports initial, feedback, and hitl templates.
82
80
  initial: |-
83
- Task system: {{taskSystem}}
84
- Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
81
+ Read the description of your assigned Jira task:
82
+ acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
85
83
 
86
- Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
84
+ Follow the work instructions, valid routes, and report format in that description.
87
85
 
88
86
  feedback: |-
89
- New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.
87
+ Continue {{node}} from the latest update on your assigned Jira task:
88
+ acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
90
89
 
90
+ # A node needing parent details can request this additional instruction
91
+ # in its node description (it is not included by default):
92
+ # Read the parent Jira ticket for this step:
93
+ # acli jira workitem view "{{ticket}}" --fields "summary,description" --json
91
94
  hitl: |-
92
95
  Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.
93
96
 
@@ -135,12 +138,13 @@ repos:
135
138
  # harnessPlugin: pi
136
139
  # harnessConfig:
137
140
  # initial: |
138
- # Task system: {{taskSystem}}
139
- # Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
141
+ # Read the description of your assigned Jira task:
142
+ # acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
140
143
  #
141
- # Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
144
+ # Follow the work instructions, valid routes, and report format in that description.
142
145
  # feedback: |
143
- # New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.
146
+ # Continue {{node}} from the latest update on your assigned Jira task:
147
+ # acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
144
148
  #
145
149
  # Pi accepts only initial and feedback harness templates. Pi workflow nodes
146
150
  # may use agent: default or a native project prompt template in
@@ -56,6 +56,9 @@ nodes:
56
56
  type: agent
57
57
  agent: build
58
58
  description: |
59
+ Read the parent Jira ticket for this step:
60
+ acli jira workitem view "{{ticket}}" --fields "summary,description" --json
61
+
59
62
  Implement the parent ticket in the ticket worktree.
60
63
  Run the relevant tests and verification commands.
61
64
  nudgePrompt: |
@@ -80,7 +80,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
80
80
  if err != nil {
81
81
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
82
82
  }
83
- specs[i].Description = appendText(custom, specs[i].Description)
83
+ if data.Report != "" && strings.Contains(custom, data.Report) {
84
+ specs[i].Description = custom
85
+ } else {
86
+ specs[i].Description = appendText(custom, specs[i].Description)
87
+ }
84
88
  }
85
89
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
86
90
  }
@@ -488,26 +492,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
488
492
  var b strings.Builder
489
493
  b.WriteString(`Required report format:
490
494
 
491
- STATUS: success | failure
492
- NEXT STEP: <one valid node name>
493
-
494
- SUMMARY:
495
- COMPLETED:
496
- COMMITS:
497
- NOT COMPLETED:
498
- ISSUES DISCOVERED:
499
- VERIFICATION:
500
- NOTES:
501
-
502
- FEEDBACK:
503
- REASON FOR NEXT STEP:
504
- REQUIRED ACTIONS:
505
- RELEVANT CONTEXT:
506
- EXPECTED RESULT:
495
+ ` + workflow.ReportFormat + `
507
496
 
508
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
509
-
510
- Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
497
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
511
498
  writeRoutes := func(label string, routes []workflow.Route) {
512
499
  if len(routes) == 0 {
513
500
  return
@@ -535,7 +522,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
535
522
  Agent: n.Agent, NodeDescription: n.Description,
536
523
  NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
537
524
  SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
538
- Mailbox: ticketKey + ":" + name,
525
+ Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
539
526
  },
540
527
  }
541
528
  }
@@ -569,7 +556,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
569
556
  if err != nil {
570
557
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
571
558
  }
572
- specs[i].Description = appendText(custom, specs[i].Description)
559
+ if data.Report != "" && strings.Contains(custom, data.Report) {
560
+ specs[i].Description = custom
561
+ } else {
562
+ specs[i].Description = appendText(custom, specs[i].Description)
563
+ }
573
564
  }
574
565
  return specs, nil
575
566
  }
@@ -72,13 +72,16 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
72
72
  t.Fatal(err)
73
73
  }
74
74
  want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " 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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
75
+ if taskSystem == "jira" {
76
+ want = "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
77
+ }
75
78
  if prompt != want {
76
79
  t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
77
80
  }
78
- if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
81
+ if taskSystem != "jira" && (strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask")) {
79
82
  t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
80
83
  }
81
- if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
84
+ if strings.Contains(prompt, "STATUS:") || (taskSystem != "jira" && strings.Contains(prompt, node.Description)) {
82
85
  t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
83
86
  }
84
87
  }
@@ -467,6 +470,7 @@ func TestTransitionOrdering(t *testing.T) {
467
470
  log := newEventLog()
468
471
  sys := newFakeTaskSystem(log)
469
472
  fr := newFakeRunner(log)
473
+ fh := newFakeHarness(log)
470
474
  wf := workflow.Workflow{
471
475
  Name: "reviewFlow", Repos: []string{"payments"},
472
476
  Nodes: map[string]workflow.Node{
@@ -485,7 +489,7 @@ func TestTransitionOrdering(t *testing.T) {
485
489
  },
486
490
  }
487
491
  engine := newEngine(t, goworkflows.Dependencies{
488
- Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
492
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
489
493
  })
490
494
  rid, _ := startRun(engine, wf)
491
495
  waitFor(t, 10*time.Second, func() bool {
@@ -510,6 +514,16 @@ func TestTransitionOrdering(t *testing.T) {
510
514
  t.Fatalf("HITL status was not set before terminal start; events=%v", events)
511
515
  }
512
516
 
517
+ var sawReviewFeedback bool
518
+ for _, call := range fh.promptCalls() {
519
+ if call.Data.Node == "review" && call.Data.PreviousFeedback == "review it" {
520
+ sawReviewFeedback = true
521
+ }
522
+ }
523
+ if !sawReviewFeedback {
524
+ t.Fatalf("next node prompt did not receive selected feedback: %+v", fh.promptCalls())
525
+ }
526
+
513
527
  // Exact cross-primitive order, observed through the fake-adapter and fake-
514
528
  // runner call logs (the settled observation seam):
515
529
  // summary(current) -> feedback(selected next) -> CompleteMailbox(current)
@@ -205,6 +205,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
205
205
  }
206
206
 
207
207
  current := target
208
+ previousFeedback := ""
208
209
  seenReportIDs := map[string]bool{}
209
210
  lastStepByNode := map[string]int64{}
210
211
  lastDepthByNode := map[string]int{}
@@ -297,16 +298,17 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
297
298
  Title: title,
298
299
  NudgePrompt: node.NudgePrompt,
299
300
  PromptData: harness.PromptData{
300
- TaskSystem: a.TaskSystem,
301
- Ticket: start.Ticket.Key,
302
- Workflow: wf.Name,
303
- Repo: start.Repo,
304
- Node: current,
305
- NodeType: node.Type,
306
- Agent: node.Agent,
307
- NodeDescription: node.Description,
308
- NextSteps: nextStepsText(nextSteps),
309
- Mailbox: mb.Key,
301
+ TaskSystem: a.TaskSystem,
302
+ Ticket: start.Ticket.Key,
303
+ Workflow: wf.Name,
304
+ Repo: start.Repo,
305
+ Node: current,
306
+ NodeType: node.Type,
307
+ Agent: node.Agent,
308
+ NodeDescription: node.Description,
309
+ NextSteps: nextStepsText(nextSteps),
310
+ Mailbox: mb.Key,
311
+ PreviousFeedback: previousFeedback,
310
312
  },
311
313
  NextSteps: nextSteps,
312
314
  }
@@ -395,7 +397,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
395
397
  stepMessage := report.Summary.Completed
396
398
  if report.Status == workflow.OutcomeFailure {
397
399
  stepStatus = run.StepFailed
398
- stepMessage = report.Summary.IssuesDiscovered
400
+ if report.Summary.IssuesDiscovered != workflow.None {
401
+ stepMessage = report.Summary.IssuesDiscovered
402
+ }
399
403
  }
400
404
  stepFinished := goworkflow.Now(ctx).UTC()
401
405
  if _, err := retryLoop(ctx, start.ID, a, work, current,
@@ -465,6 +469,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
465
469
  return err
466
470
  }
467
471
 
472
+ previousFeedback = report.Feedback.RequiredActions
468
473
  current = next
469
474
  }
470
475
 
@@ -761,11 +766,19 @@ func nextStepsText(routes []workflow.Route) string {
761
766
  }
762
767
 
763
768
  func renderSummaryReport(r workflow.Report) string {
769
+ if r.Summary.Commits == workflow.None && r.Summary.NotCompleted == workflow.None &&
770
+ r.Summary.IssuesDiscovered == workflow.None && r.Summary.Verification == workflow.None && r.Summary.Notes == workflow.None {
771
+ return r.Summary.Completed
772
+ }
764
773
  return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
765
774
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
766
775
  }
767
776
 
768
777
  func renderFeedbackReport(r workflow.Report) string {
778
+ if r.Summary.Commits == workflow.None && r.Feedback.ReasonForNextStep == workflow.None &&
779
+ r.Feedback.RelevantContext == workflow.None && r.Feedback.ExpectedResult == workflow.None {
780
+ return r.Feedback.RequiredActions
781
+ }
769
782
  return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
770
783
  r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
771
784
  }
@@ -41,6 +41,33 @@ func threeNodeWorkflow() workflow.Workflow {
41
41
  }
42
42
  }
43
43
 
44
+ func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
45
+ wf := threeNodeWorkflow()
46
+ sys := newFakeTaskSystem(newEventLog())
47
+ sys.renderText = func(_ task.TextKind, data task.TextData) (string, error) {
48
+ return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
49
+ "\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
50
+ "\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
51
+ }
52
+ specs, err := goworkflows.RenderMailboxSpecs(sys, run.Work{
53
+ Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
54
+ }, &wf)
55
+ if err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ for _, spec := range specs {
59
+ for _, required := range []string{"PAY-101 / " + spec.Node, wf.Nodes[spec.Node].Description,
60
+ "Success routes:", "Failure routes:", " — when: ", workflow.ReportFormat} {
61
+ if !strings.Contains(spec.Description, required) {
62
+ t.Fatalf("mailbox %s description lacks %q: %q", spec.Node, required, spec.Description)
63
+ }
64
+ }
65
+ if strings.Contains(spec.Description, "Required report format:") {
66
+ t.Fatalf("mailbox %s appended generic instructions: %q", spec.Node, spec.Description)
67
+ }
68
+ }
69
+ }
70
+
44
71
  func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
45
72
  log := newEventLog()
46
73
  sys := newFakeTaskSystem(log)
@@ -107,7 +134,7 @@ func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
107
134
  t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
108
135
  }
109
136
  }
110
- for _, required := range []string{"Required report format:", "STATUS:", "COMMITS:", "FEEDBACK:"} {
137
+ for _, required := range []string{"Required report format:", "STATUS:", "NEXT STEP:", "SUMMARY:", "FEEDBACK:"} {
111
138
  if !strings.Contains(d, required) {
112
139
  t.Fatalf("%s description lacks %q: %q", node, required, d)
113
140
  }
@@ -85,7 +85,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
85
85
  if err != nil {
86
86
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
87
87
  }
88
- specs[i].Description = appendText(custom, specs[i].Description)
88
+ if data.Report != "" && strings.Contains(custom, data.Report) {
89
+ specs[i].Description = custom
90
+ } else {
91
+ specs[i].Description = appendText(custom, specs[i].Description)
92
+ }
89
93
  }
90
94
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
91
95
  }
@@ -486,26 +490,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
486
490
  var b strings.Builder
487
491
  b.WriteString(`Required report format:
488
492
 
489
- STATUS: success | failure
490
- NEXT STEP: <one valid node name>
491
-
492
- SUMMARY:
493
- COMPLETED:
494
- COMMITS:
495
- NOT COMPLETED:
496
- ISSUES DISCOVERED:
497
- VERIFICATION:
498
- NOTES:
499
-
500
- FEEDBACK:
501
- REASON FOR NEXT STEP:
502
- REQUIRED ACTIONS:
503
- RELEVANT CONTEXT:
504
- EXPECTED RESULT:
493
+ ` + workflow.ReportFormat + `
505
494
 
506
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
507
-
508
- Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
495
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
509
496
  writeRoutes := func(label string, routes []workflow.Route) {
510
497
  if len(routes) == 0 {
511
498
  return
@@ -533,7 +520,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
533
520
  Agent: n.Agent, NodeDescription: n.Description,
534
521
  NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
535
522
  SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
536
- Mailbox: ticketKey + ":" + name,
523
+ Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
537
524
  },
538
525
  }
539
526
  }
@@ -567,7 +554,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
567
554
  if err != nil {
568
555
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
569
556
  }
570
- specs[i].Description = appendText(custom, specs[i].Description)
557
+ if data.Report != "" && strings.Contains(custom, data.Report) {
558
+ specs[i].Description = custom
559
+ } else {
560
+ specs[i].Description = appendText(custom, specs[i].Description)
561
+ }
571
562
  }
572
563
  return specs, nil
573
564
  }
@@ -0,0 +1,56 @@
1
+ package temporal
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/run"
8
+ "github.com/rajpopat27/relay-flow/internal/task"
9
+ "github.com/rajpopat27/relay-flow/internal/workflow"
10
+ )
11
+
12
+ type compactMailboxRenderer struct{ *lagTaskSystem }
13
+
14
+ func (*compactMailboxRenderer) RenderText(_ task.TextKind, data task.TextData) (string, error) {
15
+ return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
16
+ "\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
17
+ "\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
18
+ }
19
+
20
+ func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
21
+ wf := workflow.Workflow{Name: "test", Nodes: map[string]workflow.Node{
22
+ "coder": {Type: workflow.NodeAgent, Agent: "coder", Description: "Implement", OnSuccess: []workflow.Route{{Target: "end"}}},
23
+ }}
24
+ specs, err := RenderMailboxSpecs(&compactMailboxRenderer{&lagTaskSystem{}}, run.Work{
25
+ Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
26
+ }, &wf)
27
+ if err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ if len(specs) != 1 {
31
+ t.Fatalf("compact mailbox descriptions = %+v", specs)
32
+ }
33
+ for _, required := range []string{"PAY-101 / coder — agent — coder", "Implement", "Success routes:\nend", "Failure routes:", workflow.ReportFormat} {
34
+ if !strings.Contains(specs[0].Description, required) {
35
+ t.Fatalf("mailbox description lacks %q: %q", required, specs[0].Description)
36
+ }
37
+ }
38
+ if strings.Contains(specs[0].Description, "Required report format:") {
39
+ t.Fatalf("generic instructions appended to Jira template: %q", specs[0].Description)
40
+ }
41
+ }
42
+
43
+ func TestConciseReportRendersOnlySelectedSummaryAndFeedback(t *testing.T) {
44
+ r := workflow.Report{
45
+ Summary: workflow.Summary{Completed: "done", Commits: workflow.None, NotCompleted: workflow.None,
46
+ IssuesDiscovered: workflow.None, Verification: workflow.None, Notes: workflow.None},
47
+ Feedback: workflow.Feedback{ReasonForNextStep: workflow.None, RequiredActions: "review it",
48
+ RelevantContext: workflow.None, ExpectedResult: workflow.None},
49
+ }
50
+ if got := renderSummaryReport(r); got != "done" {
51
+ t.Fatalf("summary comment = %q", got)
52
+ }
53
+ if got := renderFeedbackReport(r); got != "review it" {
54
+ t.Fatalf("feedback comment = %q", got)
55
+ }
56
+ }
@@ -342,6 +342,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
342
342
  state.upsertStep(startStep)
343
343
 
344
344
  current := target
345
+ previousFeedback := ""
345
346
  lastStepByNode := map[string]int64{}
346
347
  lastDepthByNode := map[string]int{}
347
348
  for current != "end" {
@@ -407,7 +408,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
407
408
  PromptData: harness.PromptData{
408
409
  TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
409
410
  Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
410
- NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key,
411
+ NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key, PreviousFeedback: previousFeedback,
411
412
  },
412
413
  NextSteps: nextSteps,
413
414
  }
@@ -516,7 +517,9 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
516
517
  stepMessage := accepted.Report.Summary.Completed
517
518
  if accepted.Report.Status == domainworkflow.OutcomeFailure {
518
519
  stepStatus = run.StepFailed
519
- stepMessage = accepted.Report.Summary.IssuesDiscovered
520
+ if accepted.Report.Summary.IssuesDiscovered != domainworkflow.None {
521
+ stepMessage = accepted.Report.Summary.IssuesDiscovered
522
+ }
520
523
  }
521
524
  stepFinished := temporalworkflow.Now(ctx).UTC()
522
525
  step.Status, step.FinishedAt, step.Message, step.Route = stepStatus, &stepFinished, stepMessage, accepted.Report.NextStep
@@ -569,6 +572,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
569
572
  return err
570
573
  }
571
574
  applyRuntimePolicy(state, current, work.Runtime)
575
+ previousFeedback = accepted.Report.Feedback.RequiredActions
572
576
  current = next
573
577
  }
574
578
 
@@ -859,11 +863,19 @@ func applyRuntimePolicy(state *workflowState, node string, policy run.RuntimePol
859
863
  }
860
864
 
861
865
  func renderSummaryReport(r domainworkflow.Report) string {
866
+ if r.Summary.Commits == domainworkflow.None && r.Summary.NotCompleted == domainworkflow.None &&
867
+ r.Summary.IssuesDiscovered == domainworkflow.None && r.Summary.Verification == domainworkflow.None && r.Summary.Notes == domainworkflow.None {
868
+ return r.Summary.Completed
869
+ }
862
870
  return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
863
871
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
864
872
  }
865
873
 
866
874
  func renderFeedbackReport(r domainworkflow.Report) string {
875
+ if r.Summary.Commits == domainworkflow.None && r.Feedback.ReasonForNextStep == domainworkflow.None &&
876
+ r.Feedback.RelevantContext == domainworkflow.None && r.Feedback.ExpectedResult == domainworkflow.None {
877
+ return r.Feedback.RequiredActions
878
+ }
867
879
  return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
868
880
  r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
869
881
  }
@@ -24,20 +24,32 @@ const (
24
24
  PromptFeedback PromptKind = "feedback"
25
25
  )
26
26
 
27
+ // Jira prompt defaults keep each agent read limited to the relevant mailbox.
28
+ // A node that needs its parent's details can request the optional parent-read
29
+ // instruction in its node description.
30
+ const JiraInitialPrompt = `Read the description of your assigned Jira task:
31
+ acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
32
+
33
+ Follow the work instructions, valid routes, and report format in that description.`
34
+
35
+ const JiraFeedbackPrompt = `Continue {{node}} from the latest update on your assigned Jira task:
36
+ acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json`
37
+
27
38
  // PromptData is the task-system-neutral data core supplies to the selected
28
39
  // harness. Harness templates, including HITL instructions, are rendered only
29
40
  // by the harness.
30
41
  type PromptData struct {
31
- TaskSystem string
32
- Ticket string
33
- Workflow string
34
- Repo string
35
- Node string
36
- NodeType workflow.NodeType
37
- Agent string
38
- NodeDescription string
39
- NextSteps string
40
- Mailbox string
42
+ TaskSystem string
43
+ Ticket string
44
+ Workflow string
45
+ Repo string
46
+ Node string
47
+ NodeType workflow.NodeType
48
+ Agent string
49
+ NodeDescription string
50
+ NextSteps string
51
+ Mailbox string
52
+ PreviousFeedback string
41
53
  }
42
54
 
43
55
  type LaunchSpec struct {
@@ -137,23 +137,35 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
137
137
  return harness.Session{}, false, nil
138
138
  }
139
139
 
140
- // RenderPrompt renders the selected session prompt, appends the harness-owned
141
- // HITL/TUI instructions for HITL nodes, then renders and appends the node's
142
- // nudge template.
140
+ // RenderPrompt renders the selected session prompt and HITL instructions.
141
+ // Jira first launches omit the node nudge so the initial read stays limited
142
+ // to the assigned mailbox description.
143
143
  func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
144
144
  var tmpl string
145
145
  switch kind {
146
146
  case harness.PromptInitial:
147
147
  tmpl = h.templates.Initial
148
+ if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
149
+ tmpl = harness.JiraInitialPrompt
150
+ }
148
151
  case harness.PromptFeedback:
149
152
  tmpl = h.templates.Feedback
153
+ if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
154
+ tmpl = harness.JiraFeedbackPrompt
155
+ }
150
156
  default:
151
157
  return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
152
158
  }
153
159
  prompt := renderTemplate(tmpl, data)
154
- if data.NodeType == workflow.NodeHITL {
160
+ if data.TaskSystem == "jira" && data.PreviousFeedback != "" {
161
+ prompt = appendPrompt(prompt, "Previous step feedback: "+data.PreviousFeedback)
162
+ }
163
+ if data.NodeType == workflow.NodeHITL && (data.TaskSystem != "jira" || h.templates.HITL != defaultHITLPrompt) {
155
164
  prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
156
165
  }
166
+ if kind == harness.PromptInitial && data.TaskSystem == "jira" {
167
+ return prompt, nil
168
+ }
157
169
  return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
158
170
  }
159
171
 
@@ -204,6 +216,7 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
204
216
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
205
217
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
206
218
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
219
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
207
220
  } {
208
221
  env[key] = value
209
222
  }
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.3.10-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.11-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -105,6 +105,30 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
105
105
  }
106
106
  }
107
107
 
108
+ func TestJiraDefaultPromptsUseOnlyMailboxReadsAndSelectedFeedback(t *testing.T) {
109
+ h := opencode.New()
110
+ data := harness.PromptData{
111
+ TaskSystem: "jira", Ticket: "PAY-101", Mailbox: "PAY-234", Node: "coder", NodeType: workflow.NodeHITL,
112
+ }
113
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, "Read the latest mailbox feedback.")
114
+ if err != nil {
115
+ t.Fatal(err)
116
+ }
117
+ wantInitial := "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
118
+ if initial != wantInitial {
119
+ t.Fatalf("Jira initial prompt = %q, want %q", initial, wantInitial)
120
+ }
121
+ data.PreviousFeedback = "Review found a missing test; add it."
122
+ continuation, err := h.RenderPrompt(harness.PromptFeedback, data, "")
123
+ if err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ wantContinuation := "Continue coder from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nPrevious step feedback: Review found a missing test; add it."
127
+ if continuation != wantContinuation {
128
+ t.Fatalf("Jira continuation = %q, want %q", continuation, wantContinuation)
129
+ }
130
+ }
131
+
108
132
  func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
109
133
  _, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
110
134
  if err == nil || !strings.Contains(err.Error(), "unknown template variable {{unknown}}") {
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.10-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.11-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -51,7 +51,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
51
51
  if err != nil {
52
52
  t.Fatalf("BuildCommand: %v", err)
53
53
  }
54
- if len(cmd.Env) != 9 {
55
- t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
54
+ if len(cmd.Env) != 10 {
55
+ t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
56
56
  }
57
57
  }
@@ -150,7 +150,7 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
150
150
  }
151
151
 
152
152
  // RenderPrompt renders the selected initial or feedback template and the
153
- // node's nudge template. Initial prompts use Pi's native prompt-template
153
+ // node's nudge template (except on a Jira first launch). Initial prompts use Pi's native prompt-template
154
154
  // command syntax; feedback is sent to an existing session and must remain
155
155
  // raw so Pi does not expand the full prompt template again. HITL approval is
156
156
  // not encoded in the prompt; the Pi extension asks for approval through
@@ -160,8 +160,14 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
160
160
  switch kind {
161
161
  case harness.PromptInitial:
162
162
  tmpl = h.templates.Initial
163
+ if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
164
+ tmpl = harness.JiraInitialPrompt
165
+ }
163
166
  case harness.PromptFeedback:
164
167
  tmpl = h.templates.Feedback
168
+ if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
169
+ tmpl = harness.JiraFeedbackPrompt
170
+ }
165
171
  default:
166
172
  return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
167
173
  }
@@ -170,7 +176,13 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
170
176
  return "", err
171
177
  }
172
178
  }
173
- prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
179
+ prompt := renderTemplate(tmpl, data)
180
+ if data.TaskSystem == "jira" && data.PreviousFeedback != "" {
181
+ prompt = appendPrompt(prompt, "Previous step feedback: "+data.PreviousFeedback)
182
+ }
183
+ if kind != harness.PromptInitial || data.TaskSystem != "jira" {
184
+ prompt = appendPrompt(prompt, renderTemplate(nudgeTemplate, data))
185
+ }
174
186
  if kind == harness.PromptInitial {
175
187
  return applyPromptTemplate(data.Agent, prompt), nil
176
188
  }
@@ -213,6 +225,7 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
213
225
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
214
226
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
215
227
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
228
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
216
229
  } {
217
230
  env[key] = value
218
231
  }
@@ -42,6 +42,7 @@ func TestBuildCommandUsesStrictPiCLIContract(t *testing.T) {
42
42
  "RELAY_FLOW_NODE_TYPE": string(base.NodeType),
43
43
  "RELAY_FLOW_NUDGE_PROMPT": base.NudgePrompt,
44
44
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
45
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
45
46
  }
46
47
 
47
48
  tests := []struct {
@@ -248,13 +249,13 @@ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string)
248
249
  t.Fatalf("read strict Pi capture: %v", err)
249
250
  }
250
251
  fields := strings.Split(string(data), "\x00")
251
- if len(fields) < 12 || fields[len(fields)-1] != "" {
252
+ if len(fields) < 13 || fields[len(fields)-1] != "" {
252
253
  t.Fatalf("malformed strict Pi capture: %q", data)
253
254
  }
254
255
  fields = fields[:len(fields)-1]
255
256
  capture := piCapture{
256
257
  cwd: fields[0],
257
- args: strings.Split(fields[10], "\x1f"),
258
+ args: strings.Split(fields[11], "\x1f"),
258
259
  env: map[string]string{
259
260
  "RELAY_FLOW_HOME": fields[1],
260
261
  "RELAY_FLOW_RUN_ID": fields[2],
@@ -265,6 +266,7 @@ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string)
265
266
  "RELAY_FLOW_NODE_TYPE": fields[7],
266
267
  "RELAY_FLOW_NUDGE_PROMPT": fields[8],
267
268
  "RELAY_FLOW_NEXT_STEPS_JSON": fields[9],
269
+ "RELAY_FLOW_REPORT_FORMAT": fields[10],
268
270
  },
269
271
  }
270
272
  return capture
@@ -325,11 +327,12 @@ fi
325
327
  [ -n "${RELAY_FLOW_NODE:-}" ] || exit 3
326
328
  [ -n "${RELAY_FLOW_NODE_TYPE:-}" ] || exit 3
327
329
  [ -n "${RELAY_FLOW_NEXT_STEPS_JSON:-}" ] || exit 3
330
+ [ -n "${RELAY_FLOW_REPORT_FORMAT:-}" ] || exit 3
328
331
 
329
- printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
332
+ printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
330
333
  "$PWD" "$RELAY_FLOW_HOME" "$RELAY_FLOW_RUN_ID" "$RELAY_FLOW_WORKFLOW" \
331
334
  "$RELAY_FLOW_REPO" "$RELAY_FLOW_TICKET" "$RELAY_FLOW_NODE" "$RELAY_FLOW_NODE_TYPE" \
332
- "${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$original_args" > "$capture"
335
+ "${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$RELAY_FLOW_REPORT_FORMAT" "$original_args" > "$capture"
333
336
  `
334
337
  if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
335
338
  t.Fatal(err)
@@ -29,7 +29,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
29
29
  if err != nil {
30
30
  t.Fatalf("RenderPrompt(initial): %v", err)
31
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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
32
+ wantInitial := "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
33
33
  if initial != wantInitial {
34
34
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
35
35
  }
@@ -38,10 +38,18 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
38
38
  if err != nil {
39
39
  t.Fatalf("RenderPrompt(feedback): %v", err)
40
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|PAY-234|review (when: ready)"
41
+ wantFeedback := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
42
42
  if feedback != wantFeedback {
43
43
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
44
44
  }
45
+ data.PreviousFeedback = "Add the missing test."
46
+ continued, err := h.RenderPrompt(harness.PromptFeedback, data, "")
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ if want := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nPrevious step feedback: Add the missing test."; continued != want {
51
+ t.Fatalf("Jira Pi continuation = %q, want %q", continued, want)
52
+ }
45
53
  }
46
54
 
47
55
  func TestPiRenderPromptHonorsNodeNudgeTimingInput(t *testing.T) {
@@ -107,11 +115,11 @@ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.
107
115
  Mailbox: "PAY-234",
108
116
  }
109
117
 
110
- initial, err := h.RenderPrompt(harness.PromptInitial, data, "nudge {{node}}")
118
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, "Read the latest mailbox feedback for {{node}}.")
111
119
  if err != nil {
112
120
  t.Fatalf("RenderPrompt(initial): %v", err)
113
121
  }
114
- wantInitial := "/coder 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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge implement"
122
+ wantInitial := "/coder Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
115
123
  if initial != wantInitial {
116
124
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
117
125
  }
@@ -120,7 +128,7 @@ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.
120
128
  if err != nil {
121
129
  t.Fatalf("RenderPrompt(feedback): %v", err)
122
130
  }
123
- wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
131
+ wantFeedback := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nfeedback nudge"
124
132
  if feedback != wantFeedback {
125
133
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
126
134
  }
@@ -61,7 +61,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
61
61
  if err != nil {
62
62
  t.Fatalf("BuildCommand: %v", err)
63
63
  }
64
- if len(cmd.Env) != 9 {
65
- t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
64
+ if len(cmd.Env) != 10 {
65
+ t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
66
66
  }
67
67
  }
@@ -66,17 +66,16 @@ const (
66
66
  defaultWorkTaskStatus = "In Progress"
67
67
  defaultEndParentStatus = "Done"
68
68
  currentUserAssigneeFilter = "currentUser()"
69
- defaultMailboxDescription = `Parent ticket: {{ticket}}
70
- Workflow: {{workflow}}
71
- Node: {{node}}
72
- Node type: {{nodeType}}
73
- Agent: {{agent}}
74
- Mailbox: {{mailbox}}
75
-
76
- Node work:
69
+ defaultMailboxDescription = `{{ticket}} / {{node}} — {{nodeType}} — {{agent}}
77
70
  {{nodeDescription}}
78
71
 
79
- Read this mailbox's comments for feedback from previous nodes.`
72
+ Success routes:
73
+ {{successRoutes}}
74
+
75
+ Failure routes:
76
+ {{failureRoutes}}
77
+
78
+ {{report}}`
80
79
  defaultSummaryComment = `Summary for {{node}}
81
80
 
82
81
  {{summaryReport}}`
@@ -93,6 +92,7 @@ var knownTextVars = map[string]bool{
93
92
  "nodeDescription": true, "nextSteps": true, "successRoutes": true,
94
93
  "failureRoutes": true, "mailbox": true, "sourceNode": true,
95
94
  "targetNode": true, "summaryReport": true, "feedbackReport": true,
95
+ "report": true,
96
96
  }
97
97
 
98
98
  // DefaultConfig supplies Jira task-system text defaults for relay-flow init.
@@ -509,6 +509,11 @@ func validateTemplates(templates Templates) error {
509
509
  }
510
510
  }
511
511
  }
512
+ for _, key := range []string{"report", "ticket", "node", "nodeType", "agent", "nodeDescription", "successRoutes", "failureRoutes"} {
513
+ if !strings.Contains(templates.MailboxDescription, "{{"+key+"}}") {
514
+ return fmt.Errorf("mailboxDescription must contain {{%s}}", key)
515
+ }
516
+ }
512
517
  if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
513
518
  return fmt.Errorf("summaryComment must contain {{summaryReport}}")
514
519
  }
@@ -535,11 +540,11 @@ func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, err
535
540
  "runID": data.RunID, "ticket": data.Ticket,
536
541
  "workflow": data.Workflow, "repo": data.Repo, "node": data.Node,
537
542
  "nodeType": data.NodeType, "agent": data.Agent,
538
- "nodeDescription": data.NodeDescription, "nextSteps": data.NextSteps,
543
+ "nodeDescription": strings.ReplaceAll(data.NodeDescription, "{{ticket}}", data.Ticket), "nextSteps": data.NextSteps,
539
544
  "successRoutes": data.SuccessRoutes, "failureRoutes": data.FailureRoutes,
540
545
  "mailbox": data.Mailbox, "sourceNode": data.SourceNode,
541
546
  "targetNode": data.TargetNode, "summaryReport": data.SummaryReport,
542
- "feedbackReport": data.FeedbackReport,
547
+ "feedbackReport": data.FeedbackReport, "report": data.Report,
543
548
  }
544
549
  return textVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
545
550
  return values[textVarPattern.FindStringSubmatch(match)[1]]
@@ -10,6 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  "github.com/rajpopat27/relay-flow/internal/run"
12
12
  "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
14
  )
14
15
 
15
16
  func TestTaskTextTemplatesExposeValues(t *testing.T) {
@@ -17,7 +18,7 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
17
18
  sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
18
19
  Name: "payments",
19
20
  RootConfig: config.RawValues{"templates": map[string]any{
20
- "mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}",
21
+ "mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}|{{report}}",
21
22
  "summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
22
23
  "feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
23
24
  }},
@@ -31,7 +32,7 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
31
32
  Node: "review", NodeType: "hitl", Agent: "reviewer", NodeDescription: "Review changes.",
32
33
  NextSteps: "implement; end", SuccessRoutes: "end", FailureRoutes: "implement",
33
34
  Mailbox: "PAY-3", SourceNode: "review", TargetNode: "implement",
34
- SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it",
35
+ SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it", Report: "STATUS: success | failure",
35
36
  }
36
37
  description, err := sys.RenderText(task.TextMailboxDescription, data)
37
38
  if err != nil {
@@ -52,12 +53,47 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
52
53
  }
53
54
  }
54
55
 
56
+ func TestJiraDefaultMailboxDescriptionIsCompact(t *testing.T) {
57
+ sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
58
+ Name: "payments", RepoConfig: testRepoConfig(nil),
59
+ })
60
+ if err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ data := task.TextData{
64
+ Ticket: "PAY-101", Node: "coder", NodeType: "agent", Agent: "coder",
65
+ NodeDescription: "Implement the change.", SuccessRoutes: "reviewer — when: ready",
66
+ FailureRoutes: "coder — when: retry", Report: workflow.ReportFormat,
67
+ }
68
+ description, err := sys.RenderText(task.TextMailboxDescription, data)
69
+ if err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ want := "PAY-101 / coder — agent — coder\nImplement the change.\n\nSuccess routes:\nreviewer — when: ready\n\nFailure routes:\ncoder — when: retry\n\n" + workflow.ReportFormat
73
+ if description != want {
74
+ t.Fatalf("description = %q, want %q", description, want)
75
+ }
76
+ if strings.Contains(description, "comment history") {
77
+ t.Fatal("Jira description requested comment history")
78
+ }
79
+ data.NodeDescription = "Read the parent Jira ticket for this step:\nacli jira workitem view \"{{ticket}}\" --fields \"summary,description\" --json"
80
+ withParent, err := sys.RenderText(task.TextMailboxDescription, data)
81
+ if err != nil || !strings.Contains(withParent, `acli jira workitem view "PAY-101" --fields "summary,description" --json`) {
82
+ t.Fatalf("node-specific parent instruction = %q, %v", withParent, err)
83
+ }
84
+ }
85
+
55
86
  func TestCommentTemplatesRequireReportValues(t *testing.T) {
56
87
  for _, tc := range []struct {
57
88
  name string
58
89
  override map[string]any
59
90
  wantError string
60
91
  }{
92
+ {name: "description", override: map[string]any{"mailboxDescription": "missing"}, wantError: "mailboxDescription must contain {{report}}"},
93
+ {name: "report only", override: map[string]any{"mailboxDescription": "{{report}}"}, wantError: "mailboxDescription must contain {{ticket}}"},
94
+ {name: "no node work", override: map[string]any{"mailboxDescription": strings.Replace(defaultMailboxDescription, "{{nodeDescription}}", "No work provided", 1)}, wantError: "mailboxDescription must contain {{nodeDescription}}"},
95
+ {name: "no success routes", override: map[string]any{"mailboxDescription": strings.Replace(defaultMailboxDescription, "{{successRoutes}}", "None", 1)}, wantError: "mailboxDescription must contain {{successRoutes}}"},
96
+ {name: "no failure routes", override: map[string]any{"mailboxDescription": strings.Replace(defaultMailboxDescription, "{{failureRoutes}}", "None", 1)}, wantError: "mailboxDescription must contain {{failureRoutes}}"},
61
97
  {name: "summary", override: map[string]any{"summaryComment": "missing"}, wantError: "summaryComment must contain {{summaryReport}}"},
62
98
  {name: "feedback", override: map[string]any{"feedbackComment": "missing"}, wantError: "feedbackComment must contain {{feedbackReport}}"},
63
99
  {name: "unknown", override: map[string]any{"mailboxDescription": "{{mailboxInstructions}}"}, wantError: "unknown template variable {{mailboxInstructions}}"},
@@ -70,6 +70,7 @@ type TextData struct {
70
70
  TargetNode string
71
71
  SummaryReport string
72
72
  FeedbackReport string
73
+ Report string
73
74
  }
74
75
 
75
76
  type Target struct {
@@ -9,6 +9,13 @@ import (
9
9
  // represents an intentionally empty section.
10
10
  const None = "None"
11
11
 
12
+ // ReportFormat is the canonical agent-facing contract. Runtime plugins parse
13
+ // these four fields into the existing structured Report wire shape.
14
+ const ReportFormat = `STATUS: success | failure
15
+ NEXT STEP: <one valid route>
16
+ SUMMARY: <concise result>
17
+ FEEDBACK: <concise handoff, or None when NEXT STEP is end>`
18
+
12
19
  type Summary struct {
13
20
  Completed string `json:"completed"`
14
21
  Commits string `json:"commits"`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.3.10-alpha",
3
+ "version": "0.3.11-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",