# inspect: split trail output into individual findings

`531792a`→[main](/content/gh/entireio/cli/commits/main/index.html)·
  
dipree·4w ago·5 files·+252 added/-33 removed

When a review profile posts to a trail, do not trust the judge prompt to produce
API-ready structure. Split multi-bullet verdicts into separate whole-change
findings before posting, preserving continuation/nested lines under each bullet.
Single verdicts keep the old one-finding behavior.

Post split findings through one trail review session using comment batches, and
strengthen the judge prompt to ask for one issue per bullet. This guards custom
prompts too because splitting happens at the trail-output boundary.

## Sessions

d79b400855fbView transcript

## Changes

5

- cmd/entire/cli

- review

- Msynthesis_prompt.go+1/-1

- Mreview_bridge.go+84/-9

- Mreview_bridge_test.go+61/-2

- Mtrail_review_cmd.go+54/-17

- Mtrail_review_cmd_test.go+52/-4

```
80 unmodified lines

81
82
83
84
84
85
86
87

80 unmodified lines

Output exactly this, nothing else:
  - One line: the verdict (approve / approve with nits / request changes) and a one-sentence reason.
  - Then a short bullet list of actionable findings, most important first, one line each with a file/symbol pointer. Omit the list entirely when nothing is actionable.
  - Then a short bullet list of actionable findings, most important first, one issue per bullet, one line each with a file/symbol pointer. Omit the list entirely when nothing is actionable.

No preamble, no section headings, no restating the diff or task, no filler. Be proportional: a clean change is a single line.`)
```

Mcmd/entire/cli/review/synthesis_prompt.go+1/-1

```
44 unmodified lines

45
46
47
48
48
49
50
51
52
53
54
54
55
56
57
58
59
60
61
58
62
63
60
64
65
66
67
2 unmodified lines

70
71
72
69
70
71
73
74
75
76
77
73
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
75
99
100
101
102
2 unmodified lines

105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161

44 unmodified lines

if strings.TrimSpace(verdict) == "" {
		return errors.New("no review output to post")
	}
	input := reviewTrailFindingInput(profileName, verdict)
	inputs := reviewTrailFindingInputs(profileName, verdict)
	return runAuthenticatedDataAPI(ctx, out, false, func(ctx context.Context, client *api.Client) error {
		target, err := resolveTrailReviewTarget(ctx, client, "")
		if err != nil {
			return err
		}
		if _, err := createTrailReviewFinding(ctx, client, target.Trail.ID, input); err != nil {
		if _, err := createTrailReviewFindings(ctx, client, target.Trail.ID, inputs); err != nil {
			return err
		}
		findingWord := "findings"
		if len(inputs) == 1 {
			findingWord = "finding"
		}
		if target.Trail.Number > 0 {
			fmt.Fprintf(out, "Posted the review verdict to trail #%d as a finding.\n", target.Trail.Number)
			fmt.Fprintf(out, "Posted the review verdict to trail #%d as %d %s.\n", target.Trail.Number, len(inputs), findingWord)
		} else {
			fmt.Fprintln(out, "Posted the review verdict to the trail as a finding.")
			fmt.Fprintf(out, "Posted the review verdict to the trail as %d %s.\n", len(inputs), findingWord)
		}
		if link := trailWebURL(target); link != "" {
			fmt.Fprintf(out, "View the trail: %s\n", link)
2 unmodified lines

})
// reviewTrailFindingInput builds the trail finding payload for a review verdict.
// The verdict spans the whole change, so it uses "whole_change" granularity: the
// API requires a valid granularity and rejects a zero/empty value with a 400.
// reviewTrailFindingInput builds the trail finding payload for one review
// verdict. The verdict spans the whole change, so it uses "whole_change"
// granularity: the API requires a valid granularity and rejects a zero/empty
// value with a 400.
func reviewTrailFindingInput(profileName, verdict string) api.TrailReviewCommentInput {
	body := strings.TrimSpace(verdict)
	return reviewTrailFindingInputWithKind(profileName, verdict, "verdict")
}

// reviewTrailFindingInputs turns a final review verdict into trail findings. If
// the verdict contains multiple top-level bullet findings, post them separately
// so a custom or weak judge prompt cannot create one mega-finding on the trail.
func reviewTrailFindingInputs(profileName, verdict string) []api.TrailReviewCommentInput {
	items := splitReviewVerdictFindings(verdict)
	if len(items) <= 1 {
		return []api.TrailReviewCommentInput{reviewTrailFindingInput(profileName, verdict)}
	}
	inputs := make([]api.TrailReviewCommentInput, 0, len(items))
	for _, item := range items {
		inputs = append(inputs, reviewTrailFindingInputWithKind(profileName, item, "finding"))
	}
	return inputs
}

func reviewTrailFindingInputWithKind(profileName, text, kind string) api.TrailReviewCommentInput {
	body := strings.TrimSpace(text)
	if p := strings.TrimSpace(profileName); p != "" {
		body = fmt.Sprintf("Review verdict (profile: %s)\n\n%s", p, body)
		body = fmt.Sprintf("Review %s (profile: %s)\n\n%s", kind, p, body)
	}
	return api.TrailReviewCommentInput{
		ClientID: generateTrailReviewClientID(),
	}
}

func splitReviewVerdictFindings(verdict string) []string {
	var findings []string
	var current strings.Builder
	flush := func() {
		item := strings.TrimSpace(current.String())
		current.Reset()
		if item != "" {
			findings = append(findings, item)
		}
	}
	for _, line := range strings.Split(strings.TrimSpace(verdict), "\n") {
		if item, ok := topLevelBulletText(line); ok {
			flush()
			current.WriteString(item)
			continue
		}
		if current.Len() == 0 {
			continue
		}
		current.WriteByte('\n')
		current.WriteString(line)
	}
	flush()
	return findings
}

func topLevelBulletText(line string) (string, bool) {
	trimmedRight := strings.TrimRight(line, " \t")
	leading := len(trimmedRight) - len(strings.TrimLeft(trimmedRight, " \t"))
	if leading != 0 {
		return "", false
	}
	trimmed := strings.TrimSpace(trimmedRight)
	if len(trimmed) < 3 {
		return "", false
	}
	if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
		return strings.TrimSpace(trimmed[2:]), true
	}
	for i, r := range trimmed {
		if r >= '0' && r <= '9' {
			continue
		}
		if r == '.' && i > 0 && i+1 < len(trimmed) && trimmed[i+1] == ' ' {
			return strings.TrimSpace(trimmed[i+2:]), true
		}
		return "", false
	}
	return "", false
}

// trailWebURL builds the browser URL for a trail, matching the server's
// `<base>/<forge>/<owner>/<repo>/trails/<number>/<branch>` layout (the web UI
// shares the API origin). Returns "" when the target lacks the parts needed for

Mcmd/entire/cli/review_bridge.go+84/-9

```

6 unmodified lines

7
8
9
10
11
12
13
14
15
16
15
17
18
19
20
8 unmodified lines

29
30
31
30
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

6 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/api"
}

const testWholeChangeGranularity = "whole_change"

func TestReviewTrailFindingInput(t *testing.T) {
	// Regression: a review verdict is not tied to a file/line, so the finding
	// must use whole_change granularity. An empty granularity is rejected by
	// the API with a 400.
	in := reviewTrailFindingInput("general", "  the verdict  ")
	if in.Location.Granularity != "whole_change" {
		if in.Location.Granularity != testWholeChangeGranularity {
			t.Errorf("granularity = %q, want whole_change", in.Location.Granularity)
		}
		if in.ClientID == "" {
			t.Fatalf("input %d missing client id", i)
		}
		if in.Body == nil {
			t.Fatalf("input %d body is nil", i)
		}
		body[i] = *in.Body
		if !strings.Contains(body[i], "Review finding (profile: general)") {
			t.Fatalf("body %d missing finding/profile header: %q", i, bodies[i])
		}
	}
}

func TestReviewTrailFindingInputsSplitsTopLevelBullets(t *testing.T) {
	verdict := `REQUEST CHANGES - multiple issues.

- **[P1] First issue:** fix the first thing
  with continuation detail
- **[P2] Second issue:** fix the second thing
  - nested detail stays with second
- **[Low] Third issue:** remove the note`

inputs := reviewTrailFindingInputs("general", verdict)
	if len(inputs) != 3 {
		t.Fatalf("inputs = %d, want 3", len(inputs))
	}
	bodies := make([]string, len(inputs))
	for i, in := range inputs {
		if in.Location.Granularity != testWholeChangeGranularity {
			t.Fatalf("input %d granularity = %q, want whole_change", i, in.Location.Granularity)
		}
		if in.ClientID == "" {
			t.Fatalf("input %d missing client id", i)
		}
		if in.Body == nil {
			t.Fatalf("input %d body is nil", i)
		}
		bodies[i] = *in.Body
		if !strings.Contains(bodies[i], "Review finding (profile: general)") {
			t.Fatalf("body %d missing finding/profile header: %q", i, bodies[i])
		}
	}
	if !strings.Contains(bodies[0], "First issue") || !strings.Contains(bodies[0], "with continuation detail") {
		t.Fatalf("first body did not preserve first finding: %q", bodies[0])
	}
	if !strings.Contains(bodies[1], "Second issue") || !strings.Contains(bodies[1], "nested detail stays with second") {
		t.Fatalf("second body did not preserve nested detail: %q", bodies[1])
	}
	if strings.Contains(bodies[0], "Second issue") || strings.Contains(bodies[1], "Third issue") {
		t.Fatalf("bodies were not split cleanly: %#v", bodies)
	}
}

func TestReviewTrailFindingInputsSingleVerdictUnchanged(t *testing.T) {
	inputs := reviewTrailFindingInputs("general", "APPROVE - no actionable findings.")
	if len(inputs) != 1 {
		t.Fatalf("inputs = %d, want 1", len(inputs))
	}
	if inputs[0].Body == nil || !strings.Contains(*inputs[0].Body, "Review verdict (profile: general)") {
		t.Fatalf("single body = %v, want verdict/profile header", inputs[0].Body)
	}
}

func TestSplitReviewVerdictFindingsNumberedList(t *testing.T) {
	items := splitReviewVerdictFindings("Verdict\n\n1. First\n2. Second")
	if len(items) != 2 || items[0] != "First" || items[1] != "Second" {
		t.Fatalf("items = %#v, want numbered findings", items)
	}
}

func TestTrailWebURL(t *testing.T) {
	t.Setenv(api.BaseURLEnvVar, "https://entire.io")
}

Mcmd/entire/cli/review_bridge_test.go+61/-2

```

768 unmodified lines

769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
774
790
791
776
777
778
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
780
814
815
816
817
784
818
819
820
821
788
822
823
824
791
825
826
793
794
795
796
827
828
829
830
831
832
833
834
798
835
836
837
838
839
800
801
802
803
840
841
842
843

768 unmodified lines

// start a review session, then submit a one-item comment batch under it. It
// returns the created (or already-existing) finding.
func createTrailReviewFinding(ctx context.Context, client *api.Client, trailID string, input api.TrailReviewCommentInput) (api.TrailReviewComment, error) {
	findings, err := createTrailReviewFindings(ctx, client, trailID, []api.TrailReviewCommentInput{input})
	if err != nil {
		return api.TrailReviewComment{}, err
	}
	if len(findings) == 0 {
		return api.TrailReviewComment{}, errors.New("create finding: server returned no results")
	}
	return findings[0], nil
}

// createTrailReviewFindings posts findings through one trail review session,
// chunking by the server-advertised batch limit when present.
func createTrailReviewFindings(ctx context.Context, client *api.Client, trailID string, inputs []api.TrailReviewCommentInput) ([]api.TrailReviewComment, error) {
	if len(inputs) == 0 {
		return nil, errors.New("create finding: no findings to post")
	}
	review, err := startTrailReview(ctx, client, trailID)
	if err != nil {
		return api.TrailReviewComment{}, err
	}
	resp, err := client.Post(ctx, trailReviewBatchCommentsPath(trailID, review.ReviewID), api.TrailReviewCommentBatchRequest{
		Comments: []api.TrailReviewCommentInput{input},
	})
	limit := review.Limits.MaxCommentsPerBatch
	if limit <= 0 || limit > len(inputs) {
		limit = len(inputs)
	}
	findings := make([]api.TrailReviewComment, 0, len(inputs))
	for start := 0; start < len(inputs); start += limit {
		end := start + limit
		if end > len(inputs) {
			end = len(inputs)
		}
		batchFindings, err := postTrailReviewFindingBatch(ctx, client, trailID, review.ReviewID, inputs[start:end])
		if err != nil {
			return nil, err
		}
		findings = append(findings, batchFindings...)
	}
	return findings, nil
}

func postTrailReviewFindingBatch(ctx context.Context, client *api.Client, trailID, reviewID string, inputs []api.TrailReviewCommentInput) ([]api.TrailReviewComment, error) {
	resp, err := client.Post(ctx, trailReviewBatchCommentsPath(trailID, reviewID), api.TrailReviewCommentBatchRequest{Comments: inputs})
	if err != nil {
		return api.TrailReviewComment{}, fmt.Errorf("create finding: %w", err)
	}
	defer resp.Body.Close()
	if err := checkTrailResponse(resp); err != nil {
		return api.TrailReviewComment{}, err
	}
	var batch api.TrailReviewCommentBatchResponse
	if err := api.DecodeJSON(resp, &batch); err != nil {
		return api.TrailReviewComment{}, fmt.Errorf("decode finding batch response: %w", err)
	}
	if len(batch.Results) == 0 {
		return api.TrailReviewComment{}, errors.New("create finding: server returned no results")
	}
	result := batch.Results[0]
	if result.Status == trailReviewBatchResultError {
		if result.Error != nil {
			return api.TrailReviewComment{}, fmt.Errorf("create finding: %s: %s", result.Error.Code, result.Error.Message)
		}
		return api.TrailReviewComment{}, errors.New("create finding: server reported an error")
		return api.TrailReviewComment{}, errors.New("create finding: server reported an error")
	}
	if result.Comment == nil {
		return api.TrailReviewComment{}, errors.New("create finding: server did not return the finding")
	}
	results = append(results, *result.Comment)
	}
	return findings, nil
}

// startTrailReview opens a review session for a trail. The body is left empty
