@kitman-sh/software-engineering 0.0.0-stage → 0.10.55

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.
@@ -0,0 +1,225 @@
1
+ import { agentWork, artifact, defineLoop, LoopOutcome, note, PlanTickets } from '@kitman-sh/sdk/loop'
2
+ import * as Effect from 'effect/Effect'
3
+ import * as Schema from 'effect/Schema'
4
+
5
+ export class ResearchQuestionMissing extends Schema.TaggedError<ResearchQuestionMissing>()('ResearchQuestionMissing', {
6
+ message: Schema.String
7
+ }) {}
8
+
9
+ const Input = Schema.Struct({
10
+ /** The Task whose question to research. */
11
+ taskId: Schema.optional(Schema.String),
12
+ /** The question, when there is no Task. */
13
+ words: Schema.optional(Schema.String),
14
+ /** The repository to read; the Team's default repository when left out. */
15
+ repoBindingId: Schema.optional(Schema.String)
16
+ })
17
+
18
+ const Sources = Schema.NonEmptyArray(Schema.String)
19
+
20
+ const Fix = Schema.Struct({
21
+ /** What to build, in words a Build starts from. */
22
+ words: Schema.String,
23
+ outcome: LoopOutcome
24
+ })
25
+
26
+ const NextSteps = Schema.Struct({ tickets: PlanTickets })
27
+
28
+ const Report = Schema.Struct({
29
+ /** The Task's title, or a short title for the question. */
30
+ title: Schema.String,
31
+ summary: Schema.String,
32
+ findings: Schema.Array(
33
+ Schema.Struct({
34
+ finding: Schema.String,
35
+ /** observed: you ran it and saw it; read: a source says so; inferred: you concluded it. */
36
+ basis: Schema.Literals(['observed', 'read', 'inferred']),
37
+ sources: Sources
38
+ })
39
+ ),
40
+ /** Every primary source the report cites. */
41
+ sources: Sources,
42
+ /** Each source it needed and could not reach, with what it tried. */
43
+ unreached: Schema.Array(Schema.Struct({ source: Schema.String, tried: Schema.String })),
44
+ openQuestions: Schema.Array(Schema.String),
45
+ nextSteps: Schema.Array(Schema.String),
46
+ /** The change the report proposes, when it proposes one; outcome bug when the question is a bug report. */
47
+ fix: Schema.optionalKey(Fix),
48
+ /** The next steps as tickets someone may file as Tasks. */
49
+ nextStepTasks: Schema.optionalKey(NextSteps)
50
+ })
51
+
52
+ type Report = typeof Report.Type
53
+
54
+ const Verdict = Schema.Struct({
55
+ /** Whether the conclusion follows from the cited evidence. */
56
+ supported: Schema.Boolean,
57
+ /** What the conclusion claims that its evidence does not show. */
58
+ gaps: Schema.Array(Schema.String)
59
+ })
60
+
61
+ const Output = Schema.Struct({
62
+ reportArtifactId: Schema.String,
63
+ /** Gaps the reviewer still found after one more pass. */
64
+ gaps: Schema.Array(Schema.String),
65
+ /** The sources the report needed and could not reach. */
66
+ unreached: Schema.Array(Schema.String),
67
+ fix: Schema.optionalKey(Fix),
68
+ nextStepTasks: Schema.optionalKey(NextSteps)
69
+ })
70
+
71
+ const researchPrompt = (words: string | undefined) =>
72
+ [
73
+ words === undefined
74
+ ? "Research the question this Task asks, using the `research` skill. The Task's title, description, Acceptance Criteria and comments are in your context."
75
+ : `Research this, using the \`research\` skill: ${words}`,
76
+ 'If your context has an earlier research report on this, extend it: keep what still holds, check what may have changed, and answer what it left open. Do not start over.',
77
+ 'Cite primary sources for every finding: the code, official documentation, specifications and first-party APIs. You may run things to reproduce behaviour; change nothing in the repository. Mark each finding observed (you ran it and saw it), read (a source says so) or inferred (you concluded it).',
78
+ 'List every source you needed and could not reach under `unreached`, with what you tried, and lower your conclusions to what the sources you did reach show.',
79
+ 'If the question turns out to be a bug report, say so in the summary and give `fix` with outcome bug. If the report proposes any other change, give `fix` with its outcome. Give next steps worth doing as `nextStepTasks` tickets too.',
80
+ 'Return the report as your Output. Do not write it into the repository.'
81
+ ].join('\n\n')
82
+
83
+ const reviewPrompt = (report: Report) =>
84
+ [
85
+ 'Judge whether this research report is supported: does each conclusion follow from the evidence it cites? Check the cited sources where you can.',
86
+ 'Answer supported, and list every gap: a claim its evidence does not show, including one that rests on a source listed as unreached. Do not ask for more scope; judge what it claims.',
87
+ '```json',
88
+ JSON.stringify(report, null, 2),
89
+ '```'
90
+ ].join('\n')
91
+
92
+ const againPrompt = (words: string | undefined, gaps: readonly string[]) =>
93
+ [
94
+ researchPrompt(words),
95
+ 'A reviewer found these claims unsupported by their evidence. Support each with evidence, or drop or weaken the claim:',
96
+ ...gaps.map(gap => `- ${gap}`)
97
+ ].join('\n\n')
98
+
99
+ const list = (items: readonly string[]) => (items.length === 0 ? ['None.'] : items.map(item => `- ${item}`))
100
+
101
+ const reportMarkdown = (report: Report, gaps: readonly string[]) =>
102
+ [
103
+ `# ${report.title}`,
104
+ '',
105
+ report.summary,
106
+ '',
107
+ ...(gaps.length === 0 ? [] : ['## Gaps', '', 'A reviewer found these unsupported:', '', ...list(gaps), '']),
108
+ '## Findings',
109
+ '',
110
+ ...report.findings.flatMap(({ finding, basis, sources }) => [
111
+ `- ${finding} (${basis})`,
112
+ ...sources.map(source => ` - ${source}`)
113
+ ]),
114
+ '',
115
+ '## Sources',
116
+ '',
117
+ ...list(report.sources),
118
+ '',
119
+ ...(report.unreached.length === 0
120
+ ? []
121
+ : ['## Out of reach', '', ...report.unreached.map(({ source, tried }) => `- ${source}: ${tried}`), '']),
122
+ '## Open questions',
123
+ '',
124
+ ...list(report.openQuestions),
125
+ '',
126
+ '## Next steps',
127
+ '',
128
+ ...list(report.nextSteps),
129
+ ...(report.fix === undefined ? [] : ['', '## Proposed fix', '', `${report.fix.words} (${report.fix.outcome})`])
130
+ ].join('\n')
131
+
132
+ const sample = {
133
+ title: 'Does the API cap request bodies?',
134
+ summary: 'It does, at 1 MB.',
135
+ findings: [
136
+ {
137
+ finding: 'The server sets maxRequestBodySize to 1 MB.',
138
+ basis: 'read' as const,
139
+ sources: ['apps/api/src/server.ts:20']
140
+ }
141
+ ],
142
+ sources: ['apps/api/src/server.ts:20'],
143
+ unreached: [],
144
+ openQuestions: [],
145
+ nextSteps: []
146
+ }
147
+
148
+ /**
149
+ * Researches a Task's question or words against primary sources, reproducing what it can. A reviewer checks the
150
+ * conclusion follows from the evidence; an unsupported one goes back once, then ships with its gaps stated. The
151
+ * settled Run offers filing the next steps as Tasks and building a proposed fix. A re-run extends the earlier report.
152
+ */
153
+ export default defineLoop({
154
+ id: 'research',
155
+ version: '2.0.0',
156
+ title: 'Research',
157
+ input: Input,
158
+ output: Output,
159
+ outcome: 'investigation',
160
+ agents: { researcher: 'researcher', reviewer: 'reviewer' },
161
+ checkout: 'read',
162
+ capabilities: ['agent_work'],
163
+ offers: [
164
+ { id: 'file-next-steps', title: 'File next steps as Tasks', loop: 'file-tasks', from: 'nextStepTasks' },
165
+ { id: 'build-fix', title: 'Build the fix', loop: 'build', from: 'fix' }
166
+ ],
167
+ checks: [
168
+ {
169
+ name: 'reports',
170
+ script: { input: { taskId: 'task-1' }, outputs: { research: sample, review: { supported: true, gaps: [] } } }
171
+ }
172
+ ],
173
+ run: input =>
174
+ Effect.gen(function* () {
175
+ if (input.taskId === undefined && input.words === undefined)
176
+ return yield* new ResearchQuestionMissing({ message: 'Give the question to research, or a Task' })
177
+
178
+ const first = yield* agentWork('research', {
179
+ agent: 'researcher',
180
+ prompt: researchPrompt(input.words),
181
+ output: Report
182
+ })
183
+
184
+ const firstVerdict = yield* agentWork('review', {
185
+ agent: 'reviewer',
186
+ prompt: reviewPrompt(first),
187
+ output: Verdict
188
+ })
189
+
190
+ const report = firstVerdict.supported
191
+ ? first
192
+ : yield* agentWork('research:again', {
193
+ agent: 'researcher',
194
+ prompt: againPrompt(input.words, firstVerdict.gaps),
195
+ output: Report
196
+ })
197
+
198
+ const gaps = firstVerdict.supported
199
+ ? []
200
+ : yield* agentWork('review:again', { agent: 'reviewer', prompt: reviewPrompt(report), output: Verdict }).pipe(
201
+ Effect.map(verdict => (verdict.supported ? [] : verdict.gaps))
202
+ )
203
+
204
+ // The tickets are in the Output, where the offer to file them reads them.
205
+ const { nextStepTasks: _, ...data } = report
206
+
207
+ const { artifactId } = yield* artifact('report', {
208
+ kind: 'research_report',
209
+ title: report.title,
210
+ text: reportMarkdown(report, gaps),
211
+ data
212
+ })
213
+
214
+ // A Run from words alone has no Task thread to post to.
215
+ if (input.taskId !== undefined) yield* note('summary', { text: report.summary, artifactId })
216
+
217
+ return {
218
+ reportArtifactId: artifactId,
219
+ gaps,
220
+ unreached: report.unreached.map(({ source }) => source),
221
+ ...(report.fix === undefined ? undefined : { fix: report.fix }),
222
+ ...(report.nextStepTasks === undefined ? undefined : { nextStepTasks: report.nextStepTasks })
223
+ }
224
+ })
225
+ })
package/src/review.ts ADDED
@@ -0,0 +1,397 @@
1
+ import {
2
+ agentWork,
3
+ approveChangeRequest,
4
+ artifact,
5
+ ask,
6
+ call,
7
+ comment,
8
+ defineLoop,
9
+ type CalledLoop,
10
+ Finding,
11
+ isBlocking,
12
+ LoopOutcome,
13
+ MergedFinding,
14
+ mergeFindings,
15
+ Rebuttal,
16
+ RebuttalVerdict,
17
+ pipelineStatus,
18
+ ScopeJudgement
19
+ } from '@kitman-sh/sdk/loop'
20
+ import * as Effect from 'effect/Effect'
21
+ import * as Schema from 'effect/Schema'
22
+ import build, { BuildInput, BuildOutput } from './build'
23
+
24
+ /** Build, by its schemas: the module imports this one, so its definition is not ready when this one loads. */
25
+ const buildLoop = () => ({ id: 'build', input: BuildInput, output: BuildOutput })
26
+
27
+ /** The two reviewers: each follows one Kit's review skill, and what it looks for when that skill is not installed. */
28
+ const seats = {
29
+ 'code-review': {
30
+ skill: 'code-review',
31
+ focus:
32
+ "Review along two axes: Standards (does the change follow the repository's documented conventions?) and Spec (does it do what was asked?)."
33
+ },
34
+ quality: {
35
+ skill: 'code-review-and-quality',
36
+ focus: 'Review across correctness, readability, architecture, security and performance.'
37
+ }
38
+ } as const
39
+
40
+ const seatNames = ['code-review', 'quality'] as const
41
+
42
+ /** The seat that judges the Task's scope: its skill reviews against the spec. */
43
+ const scopeSeat = 'code-review'
44
+
45
+ const choices = ['Post to the ChangeRequest', 'Fix here', 'Build from it', 'Done'] as const
46
+
47
+ const Input = Schema.Struct({
48
+ /** Reviews this ChangeRequest, then asks what next: posting, fixing and building are each the user's call. */
49
+ changeRequestId: Schema.optional(Schema.String),
50
+ /** Or reviews `git diff base...head`, and only returns the findings. */
51
+ base: Schema.optional(Schema.String),
52
+ head: Schema.optional(Schema.String),
53
+ /**
54
+ * With `base` and `head`, reviews only these files of the change, such as the conflicts a catch-up merge settled; the
55
+ * rest was approved before. It judges no scope and approves nothing itself.
56
+ */
57
+ files: Schema.optional(Schema.Array(Schema.String)),
58
+ /** With `files`, also reviews everything in `git diff since..head`, such as a fix made after the files' review. */
59
+ since: Schema.optional(Schema.String),
60
+ /** The implementer's rebuttals of the last round's findings, for the panel to judge. */
61
+ rebuttals: Schema.optional(Schema.Array(Rebuttal)),
62
+ /** The Outcome the change must prove. */
63
+ outcome: Schema.optional(LoopOutcome),
64
+ /** What the implementer claims it built, for the scope seat to check. */
65
+ claims: Schema.optional(Schema.Array(Schema.String)),
66
+ /** An earlier review's open findings: each is checked as fixed, changed or still there, keeping its id. */
67
+ earlier: Schema.optional(Schema.Array(MergedFinding)),
68
+ /** The checks that fail on the head and pass on the base, which the implementer is already told. */
69
+ failing: Schema.optional(Schema.Array(Schema.String)),
70
+ /** What to do after a ChangeRequest's review without asking; Done when nobody answers. */
71
+ next: Schema.optional(Schema.Literals(choices))
72
+ })
73
+
74
+ const FollowUpStatus = Schema.Literals(['fixed', 'changed', 'still_there'])
75
+
76
+ const FollowUp = Schema.Struct({ id: Schema.String, status: FollowUpStatus })
77
+
78
+ const SeatOutput = Schema.Struct({
79
+ /** One sentence: can it merge, and what matters most. */
80
+ summary: Schema.String,
81
+ findings: Schema.Array(Finding),
82
+ verdicts: Schema.optional(Schema.Array(RebuttalVerdict)),
83
+ scope: Schema.optional(ScopeJudgement),
84
+ followUps: Schema.optional(Schema.Array(FollowUp))
85
+ })
86
+
87
+ const Output = Schema.Struct({
88
+ /** What the review leads with: each reviewer's one sentence. */
89
+ summary: Schema.String,
90
+ findings: Schema.Array(MergedFinding),
91
+ /** Each earlier finding, as fixed, changed but not fixed, or still there. */
92
+ followUps: Schema.Array(FollowUp),
93
+ verdicts: Schema.Array(RebuttalVerdict),
94
+ blocking: Schema.Boolean,
95
+ artifactId: Schema.String,
96
+ /** The scope seat's judgement of the Task's scope; absent when there was no Task to judge against. */
97
+ scope: Schema.optional(ScopeJudgement),
98
+ /** Kitman's approval of the ChangeRequest's head, recorded when nothing blocks it; or why it was refused. */
99
+ approval: Schema.optional(
100
+ Schema.Struct({ head: Schema.String, approved: Schema.Boolean, reason: Schema.optional(Schema.String) })
101
+ ),
102
+ /** What happened after a ChangeRequest's review. */
103
+ next: Schema.optional(Schema.Struct({ choice: Schema.Literals(choices), detail: Schema.optional(Schema.String) }))
104
+ })
105
+
106
+ export class ReviewTargetMissing extends Schema.TaggedError<ReviewTargetMissing>()('ReviewTargetMissing', {
107
+ message: Schema.String
108
+ }) {}
109
+
110
+ const targetOf = (input: typeof Input.Type, head?: string) => {
111
+ if (input.changeRequestId !== undefined)
112
+ return `ChangeRequest ${input.changeRequestId} at revision ${head}. Read it with \`kitman.changeRequests.get("${input.changeRequestId}")\` through Kitman's MCP, fetch its source branch, and review the diff of revision ${head} against its target branch, even if the branch has moved since. If the Runner cannot fetch that revision (a fork, say), report exactly that as your only finding, a blocker, and stop.`
113
+
114
+ if (input.base !== undefined && input.head !== undefined)
115
+ return input.files === undefined
116
+ ? `the change \`git diff ${input.base}...${input.head}\` in your checkout.`
117
+ : [
118
+ `only these files of the change \`git diff ${input.base}...${input.head}\` in your checkout; the rest of it was reviewed and approved before:`,
119
+ ...input.files.map(file => `- ${file}`),
120
+ ...(input.since === undefined
121
+ ? []
122
+ : [`Also review all of \`git diff ${input.since}..${input.head}\`, whichever files it touches.`])
123
+ ].join('\n')
124
+
125
+ return undefined
126
+ }
127
+
128
+ const scopePrompt = (input: typeof Input.Type) =>
129
+ [
130
+ "If your brief has a Task, judge its scope and give it as `scope`: does the change do what the Task's description and each of its Acceptance Criteria ask? Set `met` to true only if it does all of it, and list in `unmet` one line for each criterion or part of the description that is not done. If your brief has no Task, leave `scope` out.",
131
+ ...(input.outcome === undefined ? [] : [`The Outcome to prove is ${input.outcome}.`]),
132
+ ...(input.claims === undefined || input.claims.length === 0
133
+ ? []
134
+ : [
135
+ 'The implementer claims it built these. Check them against the code rather than taking them on trust:',
136
+ ...input.claims.map(claim => `- ${claim}`)
137
+ ])
138
+ ].join('\n\n')
139
+
140
+ const earlierPrompt = (earlier: readonly MergedFinding[]) =>
141
+ [
142
+ 'An earlier review found these. Give each as a `followUps` entry with its id: fixed, changed (the code moved but the problem stands) or still_there. Do not report them again as findings; review only what changed since, for new problems.',
143
+ ...earlier.map(finding => `- ${finding.id} (${finding.severity}): ${finding.detail}`)
144
+ ].join('\n')
145
+
146
+ const promptFor = (seat: keyof typeof seats, target: string, input: typeof Input.Type) =>
147
+ [
148
+ `You are one of two reviewers. Follow your \`${seats[seat].skill}\` skill; if you do not have it: ${seats[seat].focus}`,
149
+ `Review ${target}`,
150
+ 'Give `summary`: one sentence on whether it can merge and what matters most.',
151
+ 'Report each problem as a finding: severity blocker (must not ship), major (fix before merge) or minor (optional); the file path from the repository root; the line; and the detail. When it is one instance of a class of bug, name every other instance in the diff in its detail. Report nothing you have not checked in the code.',
152
+ 'If the diff is too big to review in one go, review it directory by directory and say so in a finding on the first file you skipped.',
153
+ ...(seat === scopeSeat && input.files === undefined ? [scopePrompt(input)] : []),
154
+ ...(input.earlier === undefined || input.earlier.length === 0 ? [] : [earlierPrompt(input.earlier)]),
155
+ ...(input.failing === undefined || input.failing.length === 0
156
+ ? []
157
+ : [
158
+ `These checks fail on this head and pass on the base: ${input.failing.join(', ')}. The implementer has their output, so do not report them as findings, and do not say it can merge while they fail.`
159
+ ]),
160
+ ...(input.rebuttals === undefined || input.rebuttals.length === 0
161
+ ? []
162
+ : [
163
+ 'The implementer disputes these findings. For each, give a verdict: accepted if the rebuttal is right and the finding should be dropped.',
164
+ ...input.rebuttals.map(rebuttal => `- ${rebuttal.findingId}: ${rebuttal.reason}`)
165
+ ])
166
+ ].join('\n\n')
167
+
168
+ const rank = { still_there: 0, changed: 1, fixed: 2 } as const
169
+
170
+ /** Each earlier finding's status: the worst any seat gave, and still there when no seat answered for it. */
171
+ const followUpsOf = (earlier: readonly MergedFinding[], answers: readonly (typeof FollowUp.Type)[]) =>
172
+ earlier.map(finding => {
173
+ const statuses = answers.flatMap(answer => (answer.id === finding.id ? [answer.status] : []))
174
+
175
+ return {
176
+ id: finding.id,
177
+ status:
178
+ statuses.length === 0
179
+ ? ('still_there' as const)
180
+ : statuses.reduce((worst, status) => (rank[status] < rank[worst] ? status : worst))
181
+ }
182
+ })
183
+
184
+ const reviewText = (
185
+ summary: string,
186
+ findings: readonly MergedFinding[],
187
+ followUps: readonly (typeof FollowUp.Type)[]
188
+ ) =>
189
+ [
190
+ `**Kitman review.** ${summary}`,
191
+ ...(findings.length === 0
192
+ ? []
193
+ : [
194
+ findings
195
+ .map(
196
+ finding => `- **${finding.severity}** \`${finding.id}\` ${finding.detail} _(${finding.seats.join(', ')})_`
197
+ )
198
+ .join('\n')
199
+ ]),
200
+ ...(followUps.length === 0
201
+ ? []
202
+ : [
203
+ `Earlier findings:\n${followUps.map(entry => `- \`${entry.id}\` ${entry.status.replace('_', ' ')}`).join('\n')}`
204
+ ])
205
+ ].join('\n\n')
206
+
207
+ const fixBrief = (changeRequestId: string, findings: readonly MergedFinding[]) =>
208
+ [
209
+ `Fix what a review of ChangeRequest ${changeRequestId} found, on its branch:`,
210
+ ...findings.map(finding => `- ${finding.id} (${finding.severity}): ${finding.detail}`)
211
+ ].join('\n')
212
+
213
+ /**
214
+ * Reviews a ChangeRequest or a revision range with two reviewer Agents, one following the `code-review` skill and one
215
+ * `code-review-and-quality`, and writes one review from their findings, each said once. Given an earlier review's
216
+ * findings it follows them up, keeping their ids. On a ChangeRequest it approves the head it reviewed when nothing
217
+ * blocks it, unless it wrote the change, then asks what next and posts only when told to, as one summary
218
+ * comment it edits on later reviews.
219
+ */
220
+ export default defineLoop({
221
+ id: 'review',
222
+ version: '3.3.0',
223
+ title: 'Review',
224
+ input: Input,
225
+ output: Output,
226
+ agents: { 'code-review': 'code-reviewer', quality: 'quality-reviewer' },
227
+ checkout: 'read',
228
+ // Build's, for Fix here and Build from it.
229
+ capabilities: [
230
+ 'agent_work',
231
+ 'post_comment',
232
+ 'ask',
233
+ 'call',
234
+ 'verify',
235
+ 'change_request',
236
+ 'write_back',
237
+ 'read_change_request',
238
+ 'merge_change_request',
239
+ 'approve_change_request'
240
+ ],
241
+ calls: (): readonly CalledLoop[] => [build],
242
+ offers: [
243
+ { id: 'build', title: 'Build from it', loop: 'build' },
244
+ { id: 'review', title: 'Review again', loop: 'review' }
245
+ ],
246
+ run: input =>
247
+ Effect.gen(function* () {
248
+ // The head is read first, so an approval approves the commit the reviewers read.
249
+ const head =
250
+ input.changeRequestId === undefined
251
+ ? undefined
252
+ : (yield* pipelineStatus('head', { changeRequestId: input.changeRequestId })).head
253
+
254
+ const target = targetOf(input, head)
255
+
256
+ if (target === undefined)
257
+ return yield* new ReviewTargetMissing({ message: 'Name a ChangeRequest, or both base and head revisions' })
258
+
259
+ const outputs = yield* Effect.forEach(
260
+ seatNames,
261
+ seat =>
262
+ agentWork(`review:${seat}`, {
263
+ agent: seat,
264
+ prompt: promptFor(seat, target, input),
265
+ output: SeatOutput
266
+ }).pipe(Effect.map(output => [seat, output] as const)),
267
+ { concurrency: 'unbounded' }
268
+ )
269
+
270
+ const earlier = input.earlier ?? []
271
+ const earlierIds = new Set(earlier.map(finding => finding.id))
272
+
273
+ const followUps = followUpsOf(
274
+ earlier,
275
+ outputs.flatMap(([, output]) => output.followUps ?? [])
276
+ )
277
+
278
+ const standing = new Set(followUps.flatMap(entry => (entry.status === 'fixed' ? [] : [entry.id])))
279
+
280
+ const carried = earlier.filter(finding => standing.has(finding.id))
281
+
282
+ const fresh = mergeFindings(
283
+ Object.fromEntries(
284
+ outputs.map(([seat, output]) => [
285
+ seat,
286
+ output.findings.filter(finding => !earlierIds.has(`${finding.file}:${finding.line}`))
287
+ ])
288
+ )
289
+ )
290
+
291
+ const findings = [...carried, ...fresh]
292
+
293
+ const summary =
294
+ findings.length === 0
295
+ ? 'No findings: nothing stands in the way of merging.'
296
+ : outputs.map(([, output]) => output.summary).join(' ')
297
+
298
+ const freshIds = new Set(fresh.map(finding => finding.id))
299
+ const text = reviewText(summary, findings, followUps)
300
+ const scope = input.files === undefined ? outputs.find(([seat]) => seat === scopeSeat)?.[1].scope : undefined
301
+
302
+ const stored = yield* artifact('findings', {
303
+ kind: 'review-findings',
304
+ title: 'Review findings',
305
+ text,
306
+ data: { findings, followUps }
307
+ })
308
+
309
+ const reviewed = {
310
+ summary,
311
+ findings,
312
+ followUps,
313
+ verdicts: outputs.flatMap(([, output]) => output.verdicts ?? []),
314
+ blocking: findings.some(isBlocking),
315
+ artifactId: stored.artifactId,
316
+ ...(scope === undefined ? undefined : { scope })
317
+ }
318
+
319
+ const changeRequestId = input.changeRequestId
320
+
321
+ if (changeRequestId === undefined || head === undefined) return reviewed
322
+
323
+ const approval =
324
+ reviewed.blocking || scope?.met === false || (input.failing?.length ?? 0) > 0
325
+ ? undefined
326
+ : yield* approveChangeRequest('approve', { changeRequestId, head, summary }).pipe(
327
+ Effect.map(result =>
328
+ result.approved ? { head, approved: true } : { head, approved: false, reason: result.reason }
329
+ )
330
+ )
331
+
332
+ const answer =
333
+ input.next ??
334
+ (yield* ask('next', {
335
+ prompt: `${summary}\n\nWhat next?`,
336
+ options: [
337
+ { value: 'Post to the ChangeRequest', description: 'Comments the findings on the ChangeRequest.' },
338
+ { value: 'Fix here', description: 'A Build fixes them and pushes to this ChangeRequest.' },
339
+ { value: 'Build from it', description: 'A Build fixes them in a new ChangeRequest.' },
340
+ { value: 'Done', description: 'Keeps the review as an artifact and stops.' }
341
+ ] satisfies { readonly value: (typeof choices)[number]; readonly description: string }[],
342
+ recommended: reviewed.blocking ? 'Fix here' : 'Done',
343
+ recommendationReason: reviewed.blocking
344
+ ? 'A blocking finding stops the merge, and fixing it here is the shortest way through.'
345
+ : 'Nothing blocks the merge, so the findings can wait in the Run.',
346
+ default: 'Done',
347
+ timeout: '1 hour'
348
+ }))
349
+
350
+ const choice = choices.find(option => option === answer) ?? 'Done'
351
+
352
+ const next = yield* Effect.gen(function* () {
353
+ if (choice === 'Post to the ChangeRequest') {
354
+ yield* comment('post', { changeRequestId, body: text, summary: 'kitman-review' })
355
+
356
+ // Earlier findings were anchored by the Run that found them; the summary carries them all.
357
+ const anchors = findings.flatMap(finding =>
358
+ !freshIds.has(finding.id) || finding.line < 1
359
+ ? []
360
+ : [{ file: finding.file, line: finding.line, body: `**${finding.severity}:** ${finding.detail}` }]
361
+ )
362
+
363
+ if (anchors.length === 0) return { choice }
364
+
365
+ // A line outside the diff refuses the lot; the summary comment still carries every finding.
366
+ return yield* comment('anchors', { changeRequestId, body: '', anchors }).pipe(
367
+ Effect.as({ choice }),
368
+ Effect.catchTag('StepFailed', error =>
369
+ Effect.succeed({ choice, detail: `Posted the summary; the line comments failed: ${error.reason}` })
370
+ )
371
+ )
372
+ }
373
+
374
+ if (choice === 'Fix here') {
375
+ const fixed = yield* call('fix', buildLoop(), {
376
+ words: fixBrief(changeRequestId, findings),
377
+ changeRequestId,
378
+ maxRounds: 2
379
+ })
380
+
381
+ return { choice, detail: fixed.settled ? 'Fixed and pushed' : `Pushed; left: ${fixed.unsettled.join('; ')}` }
382
+ }
383
+
384
+ if (choice === 'Build from it') {
385
+ const built = yield* call('build', buildLoop(), { words: fixBrief(changeRequestId, findings) })
386
+
387
+ return { choice, detail: built.changeRequest.url }
388
+ }
389
+
390
+ return { choice, detail: 'The review is kept as an artifact' }
391
+ }).pipe(Effect.catchTag('ChildFailed', failed => Effect.succeed({ choice, detail: failed.reason })))
392
+
393
+ return { ...reviewed, ...(approval === undefined ? undefined : { approval }), next }
394
+ })
395
+ })
396
+
397
+ export { Input as ReviewInput, Output as ReviewOutput }
@@ -0,0 +1,10 @@
1
+ import * as Schema from 'effect/Schema'
2
+
3
+ /** Shepherd's Output, outside shepherd.ts so Build can import it without the review → build → shepherd cycle. */
4
+ export const ShepherdOutput = Schema.Struct({
5
+ status: Schema.Literals(['merged', 'ready', 'needs_you']),
6
+ /** Why it stopped, in a line a person can act on. */
7
+ reason: Schema.String,
8
+ fixRounds: Schema.Int,
9
+ replies: Schema.Int
10
+ })