@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.
- package/INSTRUCTIONS.md +3 -0
- package/agents/implementer.md +41 -0
- package/agents/investigator.md +20 -0
- package/agents/planner.md +27 -0
- package/agents/releaser.md +22 -0
- package/agents/researcher.md +19 -0
- package/agents/reviewer.md +37 -0
- package/agents/writer.md +17 -0
- package/dist/bundle.js +29301 -0
- package/dist/declarations.json +3126 -0
- package/dist/setup/2a237548a69b0cd53ec3be7f687cfa8c2128335b3a655d34e185f2abce6a3c37.json +1 -0
- package/kitman.config.ts +813 -0
- package/package.json +30 -4
- package/src/build.ts +542 -0
- package/src/change-request-body.ts +95 -0
- package/src/errand.ts +142 -0
- package/src/file-tasks.ts +30 -0
- package/src/firewatch.ts +232 -0
- package/src/hillclimb.ts +407 -0
- package/src/hindsight.ts +242 -0
- package/src/index.ts +49 -0
- package/src/plan.ts +634 -0
- package/src/project-update.ts +88 -0
- package/src/release.ts +406 -0
- package/src/research.ts +225 -0
- package/src/review.ts +397 -0
- package/src/shepherd-output.ts +10 -0
- package/src/shepherd.ts +530 -0
- package/src/sweep.ts +385 -0
- package/src/tidy.ts +286 -0
- package/tsconfig.json +6 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitman-sh/software-engineering",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.55",
|
|
4
|
+
"description": "Everything for building software: Loops, their Agents, the skills they use and the Ponytail plugin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./kitman.config.ts",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"INSTRUCTIONS.md",
|
|
14
|
+
"agents",
|
|
15
|
+
"kitman.config.ts",
|
|
16
|
+
"src",
|
|
17
|
+
"tsconfig.json"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@kitman-sh/sdk": "0.10.55",
|
|
21
|
+
"effect": "4.1.2"
|
|
22
|
+
},
|
|
23
|
+
"kitman": {
|
|
24
|
+
"bundle": "dist/bundle.js",
|
|
25
|
+
"digest": "e030f00d63c0dadfeb7b0961dca0d9aeb156a3e22d9a24a116eaf90792bf822b",
|
|
26
|
+
"protocolVersion": 5,
|
|
27
|
+
"declarations": "dist/declarations.json",
|
|
28
|
+
"setup": {
|
|
29
|
+
"2a237548a69b0cd53ec3be7f687cfa8c2128335b3a655d34e185f2abce6a3c37": "dist/setup/2a237548a69b0cd53ec3be7f687cfa8c2128335b3a655d34e185f2abce6a3c37.json"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentWork,
|
|
3
|
+
agentWorkOnce,
|
|
4
|
+
approveChangeRequest,
|
|
5
|
+
ApproveChangeRequestResult,
|
|
6
|
+
call,
|
|
7
|
+
CheckName,
|
|
8
|
+
Continuing,
|
|
9
|
+
continuingBrief,
|
|
10
|
+
syncBase,
|
|
11
|
+
changeRequest,
|
|
12
|
+
ChangeRequestResult,
|
|
13
|
+
comment,
|
|
14
|
+
defineLoop,
|
|
15
|
+
type CalledLoop,
|
|
16
|
+
evidenceGate,
|
|
17
|
+
failsOn,
|
|
18
|
+
isBlocking,
|
|
19
|
+
LoopOutcome,
|
|
20
|
+
rallyStop,
|
|
21
|
+
Rebuttal,
|
|
22
|
+
settleRound,
|
|
23
|
+
verify,
|
|
24
|
+
writeBack,
|
|
25
|
+
type Disputed,
|
|
26
|
+
type MergedFinding,
|
|
27
|
+
type RallyDecision,
|
|
28
|
+
type ScopeJudgement,
|
|
29
|
+
type SettledRound,
|
|
30
|
+
type VerifyResult
|
|
31
|
+
} from '@kitman-sh/sdk/loop'
|
|
32
|
+
import * as Effect from 'effect/Effect'
|
|
33
|
+
import * as Schema from 'effect/Schema'
|
|
34
|
+
import {
|
|
35
|
+
brokenChecks,
|
|
36
|
+
changeRequestBody,
|
|
37
|
+
checkFindings,
|
|
38
|
+
isCheckFinding,
|
|
39
|
+
scopeFindings,
|
|
40
|
+
unsettledItems
|
|
41
|
+
} from './change-request-body'
|
|
42
|
+
import review from './review'
|
|
43
|
+
import shepherd from './shepherd'
|
|
44
|
+
import { ShepherdOutput } from './shepherd-output'
|
|
45
|
+
|
|
46
|
+
export const BuildInput = Schema.Struct({
|
|
47
|
+
/** What to build, when no Task says it. */
|
|
48
|
+
words: Schema.optional(Schema.String),
|
|
49
|
+
taskId: Schema.optional(Schema.String),
|
|
50
|
+
repoBindingId: Schema.optional(Schema.String),
|
|
51
|
+
/** The base branch; the repository's default branch when left out. */
|
|
52
|
+
branch: Schema.optional(Schema.String),
|
|
53
|
+
/** Where to start; the head of `branch` when left out. */
|
|
54
|
+
revision: Schema.optional(Schema.String),
|
|
55
|
+
outcome: Schema.optional(LoopOutcome),
|
|
56
|
+
/** Review rounds before the rally stops; 3 when left out. */
|
|
57
|
+
maxRounds: Schema.optional(Schema.Int),
|
|
58
|
+
/** The Task's open ChangeRequest an earlier Run left, which this Build fixes forward instead of rebuilding. */
|
|
59
|
+
continuing: Schema.optional(Continuing),
|
|
60
|
+
/** Sees the ChangeRequest through to merge with Shepherd once it opens. */
|
|
61
|
+
land: Schema.optional(Schema.Boolean),
|
|
62
|
+
/**
|
|
63
|
+
* An open ChangeRequest to fix forward on: the Run works its branch, never force-pushing, and reports in one comment
|
|
64
|
+
* on it, leaving the ChangeRequest's title and description to its author.
|
|
65
|
+
*/
|
|
66
|
+
changeRequestId: Schema.optional(Schema.String)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** The implementer's answer to one acceptance criterion of the Task. */
|
|
70
|
+
const CriterionAnswer = Schema.Struct({
|
|
71
|
+
criterion: Schema.String,
|
|
72
|
+
status: Schema.Literals(['met', 'after_merge', 'not_met']),
|
|
73
|
+
/** The test or file that shows it met, or why it is after merge or not met. */
|
|
74
|
+
detail: Schema.String
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
type CriterionAnswer = typeof CriterionAnswer.Type
|
|
78
|
+
|
|
79
|
+
const Criteria = Schema.Struct({ criteria: Schema.Array(CriterionAnswer) })
|
|
80
|
+
|
|
81
|
+
export const SuggestedCheck = Schema.Struct({
|
|
82
|
+
name: CheckName,
|
|
83
|
+
command: Schema.String,
|
|
84
|
+
workingDirectory: Schema.optionalKey(Schema.String)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const Implemented = Schema.Struct({
|
|
88
|
+
/** The ChangeRequest's title. */
|
|
89
|
+
title: Schema.String,
|
|
90
|
+
summary: Schema.String,
|
|
91
|
+
claims: Schema.Array(Schema.String),
|
|
92
|
+
/** The approved checks that run the documented commands, for a docs Outcome. */
|
|
93
|
+
documented: Schema.optional(Schema.Array(Schema.String)),
|
|
94
|
+
/** The approved check that rolls a migration back. */
|
|
95
|
+
rollback: Schema.optional(Schema.String),
|
|
96
|
+
/** Each acceptance criterion of the Task, answered; empty when it has none, left out when building from words. */
|
|
97
|
+
criteria: Schema.optional(Schema.Array(CriterionAnswer)),
|
|
98
|
+
/** Checks to run when the repository has none approved, from its package scripts or CI config. */
|
|
99
|
+
checks: Schema.optional(Schema.Array(SuggestedCheck))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const Reproduced = Schema.Struct({
|
|
103
|
+
/** The approved check that reproduces the bug. */
|
|
104
|
+
check: Schema.String,
|
|
105
|
+
summary: Schema.String
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/** A bug's proof; a continued Build recovers it from the branch an earlier Run left. */
|
|
109
|
+
const ReproducedOnBranch = Schema.Struct({
|
|
110
|
+
...Reproduced.fields,
|
|
111
|
+
/** The branch's commit where the reproducing test is in place and the fix is not yet; the head when none is. */
|
|
112
|
+
revision: Schema.optional(Schema.String)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const Fixed = Schema.Struct({
|
|
116
|
+
summary: Schema.String,
|
|
117
|
+
claims: Schema.Array(Schema.String),
|
|
118
|
+
rebuttals: Schema.Array(Rebuttal)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
export const BuildOutput = Schema.Struct({
|
|
122
|
+
changeRequest: ChangeRequestResult,
|
|
123
|
+
revision: Schema.String,
|
|
124
|
+
tree: Schema.String,
|
|
125
|
+
settled: Schema.Boolean,
|
|
126
|
+
unsettled: Schema.Array(Schema.String),
|
|
127
|
+
rounds: Schema.Int,
|
|
128
|
+
stopped: Schema.Literals(['settled', 'dispute_stands', 'findings_returned', 'round_cap']),
|
|
129
|
+
criteria: Schema.Array(CriterionAnswer),
|
|
130
|
+
/** Whether the last Review's approval was recorded, or why not, when nothing was left unsettled. */
|
|
131
|
+
approval: Schema.optional(ApproveChangeRequestResult),
|
|
132
|
+
/** What Shepherd did with the ChangeRequest, when the Build was told to land it. */
|
|
133
|
+
landed: Schema.optional(ShepherdOutput),
|
|
134
|
+
/** The input its Shepherd and Review offers start from. */
|
|
135
|
+
next: Schema.Struct({ changeRequestId: Schema.String })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
export class BuildTargetMissing extends Schema.TaggedError<BuildTargetMissing>()('BuildTargetMissing', {
|
|
139
|
+
message: Schema.String
|
|
140
|
+
}) {}
|
|
141
|
+
|
|
142
|
+
const outcomeGuidance = {
|
|
143
|
+
feature: 'Deliver the whole scope; review checks it was met.',
|
|
144
|
+
bug: 'Your failing test is already in place: make it pass.',
|
|
145
|
+
refactor: 'Keep behaviour exactly as it is; the approved verifications must pass before and after.',
|
|
146
|
+
investigation: 'Investigate and report what you found, with its sources.',
|
|
147
|
+
docs: 'Name, in `documented`, the approved checks that run the commands you documented.',
|
|
148
|
+
migration: 'Name, in `rollback`, the approved check that rolls the migration back.',
|
|
149
|
+
performance: 'Make it faster without changing behaviour.'
|
|
150
|
+
} as const satisfies { readonly [outcome in LoopOutcome]: string }
|
|
151
|
+
|
|
152
|
+
/** Build cannot prove a gain: Hillclimb commits a benchmark first and measures each change against it. */
|
|
153
|
+
export class BuildOutcomeRefused extends Schema.TaggedError<BuildOutcomeRefused>()('BuildOutcomeRefused', {
|
|
154
|
+
message: Schema.String
|
|
155
|
+
}) {}
|
|
156
|
+
|
|
157
|
+
const criteriaGuidance =
|
|
158
|
+
'If your brief has a Task, answer each of its acceptance criteria in `criteria`: met, with the test or file that shows it; after_merge, with why it can only be seen once merged; or not_met, with why. Give an empty `criteria` when the Task has none.'
|
|
159
|
+
|
|
160
|
+
const checksGuidance =
|
|
161
|
+
'If your brief lists no approved verifications for this repository, name in `checks` the commands from its package scripts or CI config that test, lint and typecheck it, each with a `name` of letters, digits, `.`, `_`, `:` and `-` only, such as `unit-tests`; Kitman runs them and labels them as suggested by you.'
|
|
162
|
+
|
|
163
|
+
/** How Build and Errand adapt the implement skill to a Step. */
|
|
164
|
+
export const implementAdaptation =
|
|
165
|
+
'You run the implement skill as a Kitman Step. The work is in this prompt and your brief, so fetch no ticket. Leave your changes uncommitted: the Run owns the branch. Skip its final code-review: a Review run or a person reviews the change.'
|
|
166
|
+
|
|
167
|
+
const implementPrompt = (task: string, outcome: LoopOutcome | undefined, continuing: Continuing | undefined) =>
|
|
168
|
+
[
|
|
169
|
+
...(continuing === undefined ? [] : [continuingBrief(continuing), 'Fix forward from there.']),
|
|
170
|
+
`Build ${task}`,
|
|
171
|
+
implementAdaptation,
|
|
172
|
+
...(outcome === undefined ? [] : [outcomeGuidance[outcome]]),
|
|
173
|
+
'Commit nothing yourself: Kitman pushes what you leave in the workspace. Give the ChangeRequest a title and a summary, and list as claims what you believe but Kitman has not checked, such as "the tests pass".',
|
|
174
|
+
criteriaGuidance,
|
|
175
|
+
checksGuidance
|
|
176
|
+
].join('\n\n')
|
|
177
|
+
|
|
178
|
+
const criteriaPrompt = (unanswered: boolean, unmet: readonly CriterionAnswer[]) =>
|
|
179
|
+
[
|
|
180
|
+
unanswered
|
|
181
|
+
? "You did not answer the Task's acceptance criteria. Answer each one before anyone reviews the change."
|
|
182
|
+
: 'These acceptance criteria are not met yet. Meet them, or say why they cannot be met here:',
|
|
183
|
+
...unmet.map(answer => `- ${answer.criterion}: ${answer.detail}`),
|
|
184
|
+
criteriaGuidance
|
|
185
|
+
].join('\n\n')
|
|
186
|
+
|
|
187
|
+
const answerLine = (answer: CriterionAnswer) =>
|
|
188
|
+
`${answer.criterion}: ${answer.status.replaceAll('_', ' ')}, ${answer.detail}`
|
|
189
|
+
|
|
190
|
+
const reproducePrompt = (task: string) =>
|
|
191
|
+
[
|
|
192
|
+
`Reproduce the bug in ${task} before anyone fixes it.`,
|
|
193
|
+
'Add a test that fails because of the bug, and change nothing else. Name the approved check that runs it; it must fail now and pass once the bug is fixed.'
|
|
194
|
+
].join('\n\n')
|
|
195
|
+
|
|
196
|
+
const reproducedOnBranchPrompt = (task: string) =>
|
|
197
|
+
[
|
|
198
|
+
`An earlier Run already worked on fixing the bug in ${task}, on this branch.`,
|
|
199
|
+
'Find the commit on this branch where the test reproducing the bug was in place and the fix was not yet, and give it as `revision`; name the approved check that runs that test. Change nothing.',
|
|
200
|
+
'If no commit on the branch has the reproducing test without the fix, add a test that fails on the branch head now, change nothing else, and give no `revision`.'
|
|
201
|
+
].join('\n\n')
|
|
202
|
+
|
|
203
|
+
const findingLine = (finding: MergedFinding) => `- **${finding.severity}** \`${finding.id}\` ${finding.detail}`
|
|
204
|
+
|
|
205
|
+
const disputeLine = ({ finding, rebuttal }: Disputed) => `${findingLine(finding)}\n You rebutted: ${rebuttal.reason}`
|
|
206
|
+
|
|
207
|
+
const fixPrompt = (settled: SettledRound) => {
|
|
208
|
+
const checks = settled.open.filter(finding => isCheckFinding(finding.id))
|
|
209
|
+
|
|
210
|
+
const findings = settled.open.filter(finding => !isCheckFinding(finding.id))
|
|
211
|
+
|
|
212
|
+
return [
|
|
213
|
+
...(checks.length === 0
|
|
214
|
+
? []
|
|
215
|
+
: [
|
|
216
|
+
'These checks fail on your head and pass on the base. Only the check passing closes one. If you are sure the change did not cause it, such as a flaky test, rebut it with your reason: the ChangeRequest reports that as your claim and the check stays open. A check does not run again on an unchanged tree, so if you change nothing and only these are open, the rally ends.',
|
|
217
|
+
...checks.map(findingLine)
|
|
218
|
+
]),
|
|
219
|
+
...(findings.length === 0
|
|
220
|
+
? []
|
|
221
|
+
: [
|
|
222
|
+
'The review found these. Fix each, or rebut it with your reason if it is wrong; reviewers judge the rebuttals next round.',
|
|
223
|
+
...findings.map(findingLine)
|
|
224
|
+
]),
|
|
225
|
+
...(settled.standing.length === 0
|
|
226
|
+
? []
|
|
227
|
+
: [
|
|
228
|
+
'The reviewers rejected your rebuttal of these. Fix each, or rebut it again with a better reason; one you leave stays a dispute for a person to settle.',
|
|
229
|
+
...settled.standing.map(disputeLine)
|
|
230
|
+
])
|
|
231
|
+
].join('\n')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const blockingIds = (settled: SettledRound) => settled.open.filter(isBlocking).map(finding => finding.id)
|
|
235
|
+
|
|
236
|
+
const onlyChecksBlock = (settled: SettledRound) =>
|
|
237
|
+
blockingIds(settled).every(isCheckFinding) && !settled.standing.some(entry => isBlocking(entry.finding))
|
|
238
|
+
|
|
239
|
+
type Round = {
|
|
240
|
+
readonly round: number
|
|
241
|
+
readonly head: VerifyResult
|
|
242
|
+
readonly settled: SettledRound
|
|
243
|
+
readonly decision: RallyDecision & { readonly stop: true }
|
|
244
|
+
readonly claims: readonly string[]
|
|
245
|
+
readonly scope: ScopeJudgement | undefined
|
|
246
|
+
readonly conflicts: readonly string[]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Verifies the head and has it reviewed, then has the implementer fix or rebut the findings, until the rally stops.
|
|
251
|
+
* The last Step that changes code comes before the last review, so the reviewed revision is the one that ships.
|
|
252
|
+
*/
|
|
253
|
+
const rally = (input: {
|
|
254
|
+
readonly maxRounds: number
|
|
255
|
+
readonly base: string
|
|
256
|
+
readonly target: string | undefined
|
|
257
|
+
readonly outcome: LoopOutcome | undefined
|
|
258
|
+
readonly claims: readonly string[]
|
|
259
|
+
readonly suggested: readonly (typeof SuggestedCheck.Type)[]
|
|
260
|
+
}) =>
|
|
261
|
+
Effect.gen(function* () {
|
|
262
|
+
const claims = [...input.claims]
|
|
263
|
+
const conflicts: string[] = []
|
|
264
|
+
let rebuttals: readonly Rebuttal[] = []
|
|
265
|
+
let earlier: readonly MergedFinding[] = []
|
|
266
|
+
let previous: readonly string[] | undefined
|
|
267
|
+
let last: Pick<Round, 'head' | 'settled' | 'scope'> | undefined
|
|
268
|
+
const shown = new Set<string>()
|
|
269
|
+
|
|
270
|
+
for (let round = 1; ; round++) {
|
|
271
|
+
// Reviewers see what will merge; a conflict the implementer cannot settle is left unsettled, not hidden.
|
|
272
|
+
const synced = yield* syncBase(`sync:${round}`, {
|
|
273
|
+
agent: 'implementer',
|
|
274
|
+
...(input.target === undefined ? undefined : { target: input.target })
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
if (synced.status === 'conflict')
|
|
278
|
+
conflicts.push(
|
|
279
|
+
`Could not merge the target branch in round ${round}: ${synced.files.join(', ')} (${synced.reason})`
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
const head = yield* verify(`verify:${round}`, { suggested: input.suggested })
|
|
283
|
+
|
|
284
|
+
// Evidence is held per tree, so the same checks would fail again and a review would judge the same change.
|
|
285
|
+
if (last !== undefined && last.head.tree === head.tree && rebuttals.length === 0 && onlyChecksBlock(last.settled))
|
|
286
|
+
return {
|
|
287
|
+
...last,
|
|
288
|
+
round: round - 1,
|
|
289
|
+
head,
|
|
290
|
+
decision: { stop: true, reason: 'findings_returned', unsettled: blockingIds(last.settled) },
|
|
291
|
+
claims,
|
|
292
|
+
conflicts
|
|
293
|
+
} satisfies Round
|
|
294
|
+
|
|
295
|
+
const failing = brokenChecks(head).map(check => check.name)
|
|
296
|
+
|
|
297
|
+
const reviewed = yield* call(`review:${round}`, review, {
|
|
298
|
+
base: input.base,
|
|
299
|
+
head: head.revision,
|
|
300
|
+
...(rebuttals.length === 0 ? undefined : { rebuttals }),
|
|
301
|
+
...(earlier.length === 0 ? undefined : { earlier }),
|
|
302
|
+
...(input.outcome === undefined ? undefined : { outcome: input.outcome }),
|
|
303
|
+
...(failing.length === 0 ? undefined : { failing }),
|
|
304
|
+
claims: [...claims]
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const checks = checkFindings(head)
|
|
308
|
+
|
|
309
|
+
const settled = settleRound({
|
|
310
|
+
findings: [...reviewed.findings, ...scopeFindings(reviewed.scope), ...checks],
|
|
311
|
+
rebuttals,
|
|
312
|
+
verdicts: reviewed.verdicts
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// A dispute that stands stays in front of the reviewers when a broken check carries the rally on past it.
|
|
316
|
+
earlier = [...settled.open, ...settled.standing.map(entry => entry.finding)].filter(
|
|
317
|
+
finding => !finding.id.startsWith('scope: ') && !isCheckFinding(finding.id)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
// A failure the implementer has not been shown has not returned, and a dispute does not keep it from them.
|
|
321
|
+
const unseen = checks.some(finding => !shown.has(finding.detail))
|
|
322
|
+
|
|
323
|
+
const decision = rallyStop({
|
|
324
|
+
round,
|
|
325
|
+
maxRounds: input.maxRounds,
|
|
326
|
+
settled,
|
|
327
|
+
...(previous === undefined || unseen ? undefined : { previous })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
if (decision.stop && (!unseen || round >= input.maxRounds))
|
|
331
|
+
return { round, head, settled, decision, claims, scope: reviewed.scope, conflicts } satisfies Round
|
|
332
|
+
|
|
333
|
+
for (const finding of checks) shown.add(finding.detail)
|
|
334
|
+
|
|
335
|
+
const fixed = yield* agentWork(`fix:${round}`, {
|
|
336
|
+
agent: 'implementer',
|
|
337
|
+
prompt: fixPrompt(settled),
|
|
338
|
+
output: Fixed
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
claims.push(
|
|
342
|
+
...fixed.claims,
|
|
343
|
+
...fixed.rebuttals.flatMap(rebuttal =>
|
|
344
|
+
isCheckFinding(rebuttal.findingId) ? [`Rebutted ${rebuttal.findingId}: ${rebuttal.reason}`] : []
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
// Evidence is not the reviewers' to withdraw.
|
|
348
|
+
const rebutted = fixed.rebuttals.filter(rebuttal => !isCheckFinding(rebuttal.findingId))
|
|
349
|
+
|
|
350
|
+
// A blocking dispute the implementer does not repeat is still one, unless the fix ends the finding.
|
|
351
|
+
rebuttals = [
|
|
352
|
+
...rebutted,
|
|
353
|
+
...settled.standing
|
|
354
|
+
.filter(entry => isBlocking(entry.finding))
|
|
355
|
+
.map(entry => entry.rebuttal)
|
|
356
|
+
.filter(rebuttal => !rebutted.some(entry => entry.findingId === rebuttal.findingId))
|
|
357
|
+
]
|
|
358
|
+
previous = blockingIds(settled)
|
|
359
|
+
last = { head, settled, scope: reviewed.scope }
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Implements a Task or words, reviews and fixes until the rally stops, gates on the evidence for the head, and opens
|
|
365
|
+
* the Run's ChangeRequest with the evidence, the claims and whatever it could not settle (ADR-0051). It writes the
|
|
366
|
+
* Run's milestones to its Task: started, ChangeRequest opened, and failed.
|
|
367
|
+
*/
|
|
368
|
+
export default defineLoop({
|
|
369
|
+
id: 'build',
|
|
370
|
+
version: '2.2.0',
|
|
371
|
+
title: 'Build',
|
|
372
|
+
input: BuildInput,
|
|
373
|
+
output: BuildOutput,
|
|
374
|
+
agents: { implementer: 'implementer' },
|
|
375
|
+
checkout: 'write',
|
|
376
|
+
offers: [
|
|
377
|
+
{ id: 'shepherd', title: 'Shepherd it to merge', loop: 'shepherd', from: 'next' },
|
|
378
|
+
{ id: 'review', title: 'Review again', loop: 'review', from: 'next' },
|
|
379
|
+
{ id: 'tidy', title: 'Tidy', loop: 'tidy' },
|
|
380
|
+
{ id: 'retry', title: 'Retry with a steer', loop: 'build' }
|
|
381
|
+
],
|
|
382
|
+
capabilities: ['agent_work', 'verify', 'change_request', 'write_back', 'post_comment', 'approve_change_request'],
|
|
383
|
+
calls: (): readonly CalledLoop[] => [review, shepherd],
|
|
384
|
+
run: input =>
|
|
385
|
+
Effect.gen(function* () {
|
|
386
|
+
if (input.words === undefined && input.taskId === undefined)
|
|
387
|
+
return yield* new BuildTargetMissing({ message: 'Give the words to build, or a Task' })
|
|
388
|
+
|
|
389
|
+
if (input.outcome === 'performance')
|
|
390
|
+
return yield* new BuildOutcomeRefused({
|
|
391
|
+
message:
|
|
392
|
+
'Build cannot prove a performance gain; start Hillclimb, which commits a benchmark and keeps only the changes Kitman measures faster'
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
yield* writeBack('write-back:started', { milestone: 'started' })
|
|
396
|
+
|
|
397
|
+
const task = input.words === undefined ? 'the Task your brief describes.' : `this: ${input.words}`
|
|
398
|
+
const outcome = input.outcome
|
|
399
|
+
const target = input.branch ?? input.continuing?.targetBranch
|
|
400
|
+
|
|
401
|
+
if (input.changeRequestId !== undefined && input.continuing?.changeRequestId !== input.changeRequestId)
|
|
402
|
+
return yield* new BuildTargetMissing({
|
|
403
|
+
message: `Kitman did not put this Run on ChangeRequest ${input.changeRequestId}'s branch`
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// A continued bug recovers its proof from the branch: the commit where the test failed before the fix.
|
|
407
|
+
const reproduced =
|
|
408
|
+
outcome === 'bug'
|
|
409
|
+
? yield* agentWork('reproduce', {
|
|
410
|
+
agent: 'implementer',
|
|
411
|
+
prompt: input.continuing === undefined ? reproducePrompt(task) : reproducedOnBranchPrompt(task),
|
|
412
|
+
output: ReproducedOnBranch
|
|
413
|
+
})
|
|
414
|
+
: undefined
|
|
415
|
+
|
|
416
|
+
const base =
|
|
417
|
+
reproduced !== undefined
|
|
418
|
+
? yield* verify(
|
|
419
|
+
'verify:reproduction',
|
|
420
|
+
reproduced.revision === undefined ? {} : { revision: reproduced.revision }
|
|
421
|
+
)
|
|
422
|
+
: outcome === 'refactor'
|
|
423
|
+
? yield* verify('verify:base', {})
|
|
424
|
+
: undefined
|
|
425
|
+
|
|
426
|
+
const implemented = yield* agentWorkOnce('implement', {
|
|
427
|
+
agent: 'implementer',
|
|
428
|
+
skill: 'implement',
|
|
429
|
+
prompt: implementPrompt(task, outcome, input.continuing),
|
|
430
|
+
output: Implemented
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// Every criterion is answered before a reviewer reads the change; what is missing or unmet goes back once.
|
|
434
|
+
// An empty list says the Task has none; only a missing one goes back.
|
|
435
|
+
const unanswered = input.taskId !== undefined && implemented.criteria === undefined
|
|
436
|
+
const unmet = (implemented.criteria ?? []).filter(answer => answer.status === 'not_met')
|
|
437
|
+
|
|
438
|
+
const criteria =
|
|
439
|
+
unanswered || unmet.length > 0
|
|
440
|
+
? (yield* agentWorkOnce('criteria', {
|
|
441
|
+
agent: 'implementer',
|
|
442
|
+
prompt: criteriaPrompt(unanswered, unmet),
|
|
443
|
+
output: Criteria
|
|
444
|
+
})).criteria
|
|
445
|
+
: (implemented.criteria ?? [])
|
|
446
|
+
|
|
447
|
+
const last = yield* rally({
|
|
448
|
+
maxRounds: input.maxRounds ?? 3,
|
|
449
|
+
// Against the target, not `revision`: a Build handed a revision, such as an Errand's head, reviews that change too.
|
|
450
|
+
base: `origin/${target ?? 'HEAD'}`,
|
|
451
|
+
target,
|
|
452
|
+
outcome,
|
|
453
|
+
claims: [...implemented.claims, ...criteria.map(answer => `Criterion ${answerLine(answer)}`)],
|
|
454
|
+
suggested: implemented.checks ?? []
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const verdict = evidenceGate({
|
|
458
|
+
...(outcome === undefined ? undefined : { outcome }),
|
|
459
|
+
head: last.head,
|
|
460
|
+
...(base === undefined ? undefined : { base }),
|
|
461
|
+
...(last.scope === undefined ? undefined : { review: last.scope }),
|
|
462
|
+
...(reproduced === undefined ? undefined : { reproducing: reproduced.check }),
|
|
463
|
+
...(implemented.documented === undefined ? undefined : { documented: implemented.documented }),
|
|
464
|
+
...(implemented.rollback === undefined ? undefined : { rollback: implemented.rollback })
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// An Outcome's gate may not read every check, so a failing one is reported here too, once.
|
|
468
|
+
const unsettled = [
|
|
469
|
+
...new Set([
|
|
470
|
+
...(verdict.settled ? [] : verdict.reasons),
|
|
471
|
+
...brokenChecks(last.head).map(check => failsOn('head', check.name, last.head.tree)),
|
|
472
|
+
...unsettledItems(last.settled, last.decision),
|
|
473
|
+
...last.conflicts,
|
|
474
|
+
...criteria.flatMap(answer =>
|
|
475
|
+
answer.status === 'not_met' ? [`Criterion not met: ${answer.criterion} (${answer.detail})`] : []
|
|
476
|
+
)
|
|
477
|
+
])
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
const body = changeRequestBody({
|
|
481
|
+
summary: implemented.summary,
|
|
482
|
+
head: last.head,
|
|
483
|
+
claims: last.claims,
|
|
484
|
+
unsettled,
|
|
485
|
+
criteria: criteria.map(answerLine),
|
|
486
|
+
later: last.settled.open
|
|
487
|
+
.filter(finding => !isBlocking(finding))
|
|
488
|
+
.map(finding => `${finding.id}: ${finding.detail}`),
|
|
489
|
+
rounds: last.round,
|
|
490
|
+
stopped: last.decision.reason
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
const named = input.changeRequestId === undefined ? undefined : input.continuing
|
|
494
|
+
|
|
495
|
+
const opened =
|
|
496
|
+
named === undefined
|
|
497
|
+
? yield* changeRequest('change-request', { title: implemented.title, body })
|
|
498
|
+
: yield* comment('report', { changeRequestId: named.changeRequestId, body, summary: 'kitman-build' }).pipe(
|
|
499
|
+
Effect.as({ id: named.changeRequestId, number: named.number, url: named.url })
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if (named === undefined)
|
|
503
|
+
yield* writeBack('write-back', { milestone: 'change_request_opened', changeRequestUrl: opened.url })
|
|
504
|
+
|
|
505
|
+
// The last round's Review passed on the head that ships, so it is the independent approval Shepherd waits for.
|
|
506
|
+
// Without one, Shepherd waits for a person's.
|
|
507
|
+
const approval =
|
|
508
|
+
unsettled.length === 0
|
|
509
|
+
? yield* approveChangeRequest('approve', {
|
|
510
|
+
changeRequestId: opened.id,
|
|
511
|
+
head: last.head.revision,
|
|
512
|
+
summary: `Review round ${last.round} passed with nothing blocking.`,
|
|
513
|
+
review: `review:${last.round}`
|
|
514
|
+
}).pipe(
|
|
515
|
+
Effect.catchTag('StepFailed', error => Effect.succeed({ approved: false as const, reason: error.reason }))
|
|
516
|
+
)
|
|
517
|
+
: undefined
|
|
518
|
+
|
|
519
|
+
const landed =
|
|
520
|
+
input.land === true
|
|
521
|
+
? yield* call('land', shepherd, {
|
|
522
|
+
changeRequestId: opened.id,
|
|
523
|
+
...(input.taskId === undefined ? undefined : { taskId: input.taskId }),
|
|
524
|
+
...(input.branch === undefined ? undefined : { branch: input.branch })
|
|
525
|
+
})
|
|
526
|
+
: undefined
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
changeRequest: opened,
|
|
530
|
+
revision: last.head.revision,
|
|
531
|
+
tree: last.head.tree,
|
|
532
|
+
settled: unsettled.length === 0,
|
|
533
|
+
unsettled,
|
|
534
|
+
rounds: last.round,
|
|
535
|
+
stopped: last.decision.reason,
|
|
536
|
+
criteria,
|
|
537
|
+
...(approval === undefined ? undefined : { approval }),
|
|
538
|
+
...(landed === undefined ? undefined : { landed }),
|
|
539
|
+
next: { changeRequestId: opened.id }
|
|
540
|
+
}
|
|
541
|
+
}).pipe(Effect.tapError(() => Effect.ignore(writeBack('write-back:failed', { milestone: 'failed' }))))
|
|
542
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { MergedFinding, RallyDecision, ScopeJudgement, SettledRound, VerifyResult } from '@kitman-sh/sdk/loop'
|
|
2
|
+
|
|
3
|
+
/** Each part of the Task the reviewer found not done, as a blocker the implementer fixes or rebuts like any other. */
|
|
4
|
+
export const scopeFindings = (scope: ScopeJudgement | undefined): readonly MergedFinding[] =>
|
|
5
|
+
(scope?.unmet ?? []).map(item => ({
|
|
6
|
+
id: `scope: ${item}`,
|
|
7
|
+
severity: 'blocker',
|
|
8
|
+
file: '',
|
|
9
|
+
line: 0,
|
|
10
|
+
detail: 'The reviewer found this part of the Task not done.',
|
|
11
|
+
seats: ['code-review']
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
/** What fails on the head and not on the base, so the change broke it. */
|
|
15
|
+
export const brokenChecks = (head: VerifyResult) =>
|
|
16
|
+
head.unchanged === true ? [] : head.checks.filter(check => check.status === 'failed' && !check.preExisting)
|
|
17
|
+
|
|
18
|
+
export const isCheckFinding = (id: string) => id.startsWith('check: ')
|
|
19
|
+
|
|
20
|
+
/** Each check the change broke, as a blocker with its output, which only the check passing closes. */
|
|
21
|
+
export const checkFindings = (head: VerifyResult): readonly MergedFinding[] =>
|
|
22
|
+
brokenChecks(head).map(check => ({
|
|
23
|
+
id: `check: ${check.name}`,
|
|
24
|
+
severity: 'blocker',
|
|
25
|
+
file: '',
|
|
26
|
+
line: 0,
|
|
27
|
+
detail: [
|
|
28
|
+
`\`${check.command}\` fails on the head and passes on the base.`,
|
|
29
|
+
...(check.outputTail === undefined ? [] : ['It printed:', '```', check.outputTail, '```'])
|
|
30
|
+
].join('\n'),
|
|
31
|
+
seats: ['verify']
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
/** Why each item the rally could not settle is still open; a failing check is the evidence's to report. */
|
|
35
|
+
export const unsettledItems = (settled: SettledRound, decision: RallyDecision & { readonly stop: true }) =>
|
|
36
|
+
decision.unsettled
|
|
37
|
+
.filter(id => !isCheckFinding(id))
|
|
38
|
+
.map(id => {
|
|
39
|
+
const standing = settled.standing.find(entry => entry.finding.id === id)
|
|
40
|
+
|
|
41
|
+
if (standing !== undefined)
|
|
42
|
+
return `Dispute stands on ${id}: ${standing.finding.detail} (rebutted: ${standing.rebuttal.reason})`
|
|
43
|
+
|
|
44
|
+
return `Unresolved ${settled.open.find(finding => finding.id === id)?.severity ?? 'blocker'} at ${id}: ${settled.open.find(finding => finding.id === id)?.detail ?? ''}`
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export const evidenceTable = (head: VerifyResult) =>
|
|
48
|
+
[
|
|
49
|
+
`Collected by a Runner for revision \`${head.revision}\`, tree \`${head.tree}\`; it holds for any revision with this tree.`,
|
|
50
|
+
'',
|
|
51
|
+
'| Check | Command | Result |',
|
|
52
|
+
'| --- | --- | --- |',
|
|
53
|
+
...head.checks.map(
|
|
54
|
+
entry =>
|
|
55
|
+
`| ${entry.name}${entry.suggested === true ? ' (suggested by the agent)' : ''} | \`${entry.command}\` | ${entry.status}${entry.status === 'failed' && (entry.preExisting || head.unchanged === true) ? ' (also fails on the base branch)' : ''} |`
|
|
56
|
+
),
|
|
57
|
+
...(head.checks.some(entry => entry.suggested === true)
|
|
58
|
+
? [
|
|
59
|
+
'',
|
|
60
|
+
"The repository has no approved verifications, so these are the agent's suggestions; check them, and approve the ones to keep with `kitman verify add`."
|
|
61
|
+
]
|
|
62
|
+
: [])
|
|
63
|
+
].join('\n')
|
|
64
|
+
|
|
65
|
+
/** A ChangeRequest's description: the summary, the head's evidence, the claims and what was left unsettled. */
|
|
66
|
+
export const changeRequestBody = (build: {
|
|
67
|
+
readonly summary: string
|
|
68
|
+
readonly head: VerifyResult
|
|
69
|
+
readonly claims: readonly string[]
|
|
70
|
+
readonly unsettled: readonly string[]
|
|
71
|
+
/** The implementer's answers to the Task's acceptance criteria. */
|
|
72
|
+
readonly criteria?: readonly string[]
|
|
73
|
+
/** Minor findings left for later; they never cost a round. */
|
|
74
|
+
readonly later?: readonly string[]
|
|
75
|
+
readonly rounds: number
|
|
76
|
+
readonly stopped: string
|
|
77
|
+
}) =>
|
|
78
|
+
[
|
|
79
|
+
build.summary,
|
|
80
|
+
'## Evidence',
|
|
81
|
+
evidenceTable(build.head),
|
|
82
|
+
...(build.criteria === undefined || build.criteria.length === 0
|
|
83
|
+
? []
|
|
84
|
+
: ['## Acceptance criteria', ...build.criteria.map(answer => `- ${answer}`)]),
|
|
85
|
+
'## Claims',
|
|
86
|
+
'What the agent says and Kitman has not checked.',
|
|
87
|
+
...(build.claims.length === 0 ? ['None.'] : build.claims.map(claim => `- ${claim}`)),
|
|
88
|
+
'## Unsettled',
|
|
89
|
+
...(build.unsettled.length === 0 ? ['Nothing is unsettled.'] : build.unsettled.map(item => `- ${item}`)),
|
|
90
|
+
...(build.later === undefined || build.later.length === 0
|
|
91
|
+
? []
|
|
92
|
+
: ['## Left for later', ...build.later.map(item => `- ${item}`)]),
|
|
93
|
+
'## Review',
|
|
94
|
+
`${build.rounds} review round${build.rounds === 1 ? '' : 's'}; the rally stopped: ${build.stopped.replaceAll('_', ' ')}.`
|
|
95
|
+
].join('\n\n')
|