@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/src/sweep.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentWork,
|
|
3
|
+
artifact,
|
|
4
|
+
call,
|
|
5
|
+
changeRequest,
|
|
6
|
+
ChangeRequestResult,
|
|
7
|
+
closeChangeRequest,
|
|
8
|
+
defineLoop,
|
|
9
|
+
openChangeRequests,
|
|
10
|
+
ownChangeRequests,
|
|
11
|
+
verify,
|
|
12
|
+
type OpenChangeRequests,
|
|
13
|
+
type VerifyResult
|
|
14
|
+
} from '@kitman-sh/sdk/loop'
|
|
15
|
+
import * as Effect from 'effect/Effect'
|
|
16
|
+
import * as Schema from 'effect/Schema'
|
|
17
|
+
import shepherd from './shepherd'
|
|
18
|
+
|
|
19
|
+
const Input = Schema.Struct({
|
|
20
|
+
/** The dependency bots' logins; Renovate's and Dependabot's on GitHub when left out. */
|
|
21
|
+
authors: Schema.optional(Schema.NonEmptyArray(Schema.String)),
|
|
22
|
+
/** The branch the batch targets; the repository's default branch when left out. */
|
|
23
|
+
branch: Schema.optional(Schema.String),
|
|
24
|
+
repoBindingId: Schema.optional(Schema.String),
|
|
25
|
+
/** Rounds of fixing what the batch breaks before an update is dropped instead; 2 when left out. */
|
|
26
|
+
maxFixRounds: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(5)))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const Decision = Schema.Literals(['batch', 'alone', 'hold'])
|
|
30
|
+
|
|
31
|
+
const Assessed = Schema.Struct({
|
|
32
|
+
updates: Schema.Array(
|
|
33
|
+
Schema.Struct({
|
|
34
|
+
number: Schema.String,
|
|
35
|
+
/** batch: safe to land with the others; alone: needs its own review; hold: should not land now. */
|
|
36
|
+
decision: Decision,
|
|
37
|
+
reason: Schema.String,
|
|
38
|
+
/** Whether it bumps a major version. */
|
|
39
|
+
major: Schema.Boolean
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const Batched = Schema.Struct({
|
|
45
|
+
/** The updates now on the branch. */
|
|
46
|
+
applied: Schema.Array(Schema.String),
|
|
47
|
+
/** The updates taken out, each with why. */
|
|
48
|
+
dropped: Schema.Array(Schema.Struct({ number: Schema.String, reason: Schema.String })),
|
|
49
|
+
summary: Schema.String
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
type Batched = typeof Batched.Type
|
|
53
|
+
|
|
54
|
+
const Placed = Schema.Struct({ number: Schema.String, title: Schema.String, url: Schema.String, reason: Schema.String })
|
|
55
|
+
|
|
56
|
+
type Placed = typeof Placed.Type
|
|
57
|
+
|
|
58
|
+
const Output = Schema.Struct({
|
|
59
|
+
/** nothing_to_sweep: the bots have nothing open; reported: nothing could be batched or it did not pass. */
|
|
60
|
+
status: Schema.Literals(['nothing_to_sweep', 'reported', 'opened', 'landed']),
|
|
61
|
+
summary: Schema.String,
|
|
62
|
+
landed: Schema.Array(Placed),
|
|
63
|
+
/** Left for a person: updates that go alone, are held, or dropped out of the batch. */
|
|
64
|
+
left: Schema.Array(Placed),
|
|
65
|
+
reportArtifactId: Schema.optional(Schema.String),
|
|
66
|
+
changeRequest: Schema.optional(ChangeRequestResult)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
type Output = typeof Output.Type
|
|
70
|
+
|
|
71
|
+
type Bot = OpenChangeRequests['changeRequests'][number]
|
|
72
|
+
|
|
73
|
+
const defaultAuthors = ['renovate[bot]', 'dependabot[bot]'] as const
|
|
74
|
+
|
|
75
|
+
const listing = (requests: readonly Bot[]) =>
|
|
76
|
+
requests
|
|
77
|
+
.map(request =>
|
|
78
|
+
[
|
|
79
|
+
`### !${request.number} ${request.title}`,
|
|
80
|
+
`Branch \`${request.sourceBranch}\` at ${request.head}, ${request.url}`,
|
|
81
|
+
request.body
|
|
82
|
+
].join('\n\n')
|
|
83
|
+
)
|
|
84
|
+
.join('\n\n')
|
|
85
|
+
|
|
86
|
+
const assessPrompt = (requests: readonly Bot[]) =>
|
|
87
|
+
[
|
|
88
|
+
'These dependency updates are open. Read each one’s changelog and breaking-change notes, and what in this repository uses it.',
|
|
89
|
+
listing(requests),
|
|
90
|
+
'Decide for each: batch when it is safe to land with the others; alone when it needs its own review, such as a breaking change that needs code changes; hold when it should not land now, saying why.',
|
|
91
|
+
'Say whether it bumps a major version. Change nothing.'
|
|
92
|
+
].join('\n\n')
|
|
93
|
+
|
|
94
|
+
const batchPrompt = (requests: readonly Bot[], target: string) =>
|
|
95
|
+
[
|
|
96
|
+
`Bring these dependency updates onto this branch, which starts from ${target}: for each, merge \`origin/<branch>\` at the head named; Kitman fetched origin just before this Step, so do not fetch. Where lockfiles conflict, regenerate them with the package manager instead of hand-merging.`,
|
|
97
|
+
listing(requests),
|
|
98
|
+
'An update that cannot be brought in cleanly is dropped: leave it out and say why. Commit nothing yourself; Kitman pushes what you leave.'
|
|
99
|
+
].join('\n\n')
|
|
100
|
+
|
|
101
|
+
const fixPrompt = (failed: VerifyResult, applied: readonly string[]) =>
|
|
102
|
+
[
|
|
103
|
+
`The batch of ${applied.map(number => `!${number}`).join(', ')} fails these checks:`,
|
|
104
|
+
...failed.checks.filter(check => check.status === 'failed').map(check => `- ${check.name}: \`${check.command}\``),
|
|
105
|
+
'Fix what an update broke, the way its changelog says to. If an update cannot be fixed in a small change, take it back out and drop it with the reason. Commit nothing yourself.'
|
|
106
|
+
].join('\n\n')
|
|
107
|
+
|
|
108
|
+
const supersedes = 'Supersedes:'
|
|
109
|
+
|
|
110
|
+
/** An update a batch brings in, at the head it was brought in at. */
|
|
111
|
+
type Landing = Placed & { readonly head: string }
|
|
112
|
+
|
|
113
|
+
const supersededLine = (update: Landing) => `- !${update.number} ${update.title} (${update.url}) at ${update.head}`
|
|
114
|
+
|
|
115
|
+
/** The updates a batch's description says it supersedes, as `supersededLine` wrote them. */
|
|
116
|
+
const supersededIn = (body: string): Landing[] => {
|
|
117
|
+
const after = body.split(supersedes)[1] ?? ''
|
|
118
|
+
const listed = after.split('Left for a person:')[0] ?? ''
|
|
119
|
+
|
|
120
|
+
return listed.split('\n').flatMap(line => {
|
|
121
|
+
const found = /^- !(\S+) (.+) \((\S+)\) at (\S+)$/u.exec(line.trim())
|
|
122
|
+
|
|
123
|
+
return found === null
|
|
124
|
+
? []
|
|
125
|
+
: [
|
|
126
|
+
{
|
|
127
|
+
number: found[1] ?? '',
|
|
128
|
+
title: found[2] ?? '',
|
|
129
|
+
url: found[3] ?? '',
|
|
130
|
+
head: found[4] ?? '',
|
|
131
|
+
reason: 'Verified in the batch'
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Closes each update a merged batch landed, with a note pointing at the batch. One the bot pushed to after it was
|
|
139
|
+
* batched stays open: its new head landed nowhere. Returns those, for a person.
|
|
140
|
+
*/
|
|
141
|
+
const closeSuperseded = (landing: readonly Landing[], batchUrl: string) =>
|
|
142
|
+
Effect.map(
|
|
143
|
+
Effect.forEach(landing, ({ head, ...update }) =>
|
|
144
|
+
Effect.map(
|
|
145
|
+
closeChangeRequest(`close:${update.number}`, {
|
|
146
|
+
number: update.number,
|
|
147
|
+
head,
|
|
148
|
+
note: `Landed in ${batchUrl} with the other dependency updates Sweep verified together.`
|
|
149
|
+
}),
|
|
150
|
+
closed =>
|
|
151
|
+
closed.head === undefined
|
|
152
|
+
? []
|
|
153
|
+
: [{ ...update, reason: `The bot pushed ${closed.head} after Sweep batched ${head}, so it stays open` }]
|
|
154
|
+
)
|
|
155
|
+
),
|
|
156
|
+
moved => moved.flat()
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
/** What landed and what is left once the updates that moved are left open. */
|
|
160
|
+
const afterClosing = (landing: readonly Landing[], left: readonly Placed[], moved: readonly Placed[]) => ({
|
|
161
|
+
landed: landing.flatMap(({ head: _head, ...update }) =>
|
|
162
|
+
moved.some(open => open.number === update.number) ? [] : [update]
|
|
163
|
+
),
|
|
164
|
+
left: [...left, ...moved]
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const reportText = (landing: readonly Placed[], left: readonly Placed[], heading: string) =>
|
|
168
|
+
[
|
|
169
|
+
heading,
|
|
170
|
+
...(landing.length === 0 ? [] : ['Landing:', ...landing.map(update => `- !${update.number} ${update.title}`)]),
|
|
171
|
+
...(left.length === 0
|
|
172
|
+
? []
|
|
173
|
+
: ['Left for a person:', ...left.map(update => `- !${update.number} ${update.title}: ${update.reason}`)])
|
|
174
|
+
].join('\n')
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Gathers the dependency bot's open ChangeRequests, lands the safe ones together, and leaves the rest explained. Each
|
|
178
|
+
* update is classified batch, alone or hold with a reason; a major version bump is never batched silently. The batch
|
|
179
|
+
* is built from the bots' heads read again just before, verified, and fixed for a bounded number of rounds; an update
|
|
180
|
+
* that still breaks drops out with its reason. One ChangeRequest names each update it supersedes and is shepherded to
|
|
181
|
+
* merge once approved; then the superseded ChangeRequests are closed with a note.
|
|
182
|
+
*/
|
|
183
|
+
export default defineLoop({
|
|
184
|
+
id: 'sweep',
|
|
185
|
+
version: '1.0.0',
|
|
186
|
+
title: 'Sweep',
|
|
187
|
+
input: Input,
|
|
188
|
+
output: Output,
|
|
189
|
+
agents: { assessor: 'investigator', implementer: 'implementer' },
|
|
190
|
+
checkout: 'write',
|
|
191
|
+
schedule: { rule: { frequency: 'weekly', daysOfWeek: [1], hour: 6, minute: 0 }, timezone: 'UTC' },
|
|
192
|
+
capabilities: ['agent_work', 'verify', 'change_request', 'read_change_request', 'merge_change_request'],
|
|
193
|
+
calls: () => [shepherd],
|
|
194
|
+
run: input =>
|
|
195
|
+
Effect.gen(function* () {
|
|
196
|
+
const authors = input.authors ?? defaultAuthors
|
|
197
|
+
|
|
198
|
+
// A batch an earlier Sweep left open is landed, not batched again beside it.
|
|
199
|
+
const [earlier] = (yield* ownChangeRequests('earlier')).changeRequests
|
|
200
|
+
|
|
201
|
+
if (earlier !== undefined) {
|
|
202
|
+
const landed = yield* call('land', shepherd, {
|
|
203
|
+
changeRequestId: earlier.id,
|
|
204
|
+
merge: true,
|
|
205
|
+
...(input.branch === undefined ? undefined : { branch: input.branch })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const summary = `${earlier.url}, from an earlier Sweep, is still open; Sweep took it on instead of batching again: ${landed.status}. ${landed.reason}`
|
|
209
|
+
// Its description names what it supersedes, so merging it closes those as a fresh batch would.
|
|
210
|
+
const landing = supersededIn(earlier.body)
|
|
211
|
+
|
|
212
|
+
const settled = afterClosing(
|
|
213
|
+
landing,
|
|
214
|
+
[],
|
|
215
|
+
landed.status === 'merged' ? yield* closeSuperseded(landing, earlier.url) : []
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const stored = yield* artifact('report', {
|
|
219
|
+
kind: 'sweep_report',
|
|
220
|
+
title: 'Sweep',
|
|
221
|
+
text: reportText(settled.landed, settled.left, summary)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
status: landed.status === 'merged' ? 'landed' : 'opened',
|
|
226
|
+
summary,
|
|
227
|
+
...settled,
|
|
228
|
+
reportArtifactId: stored.artifactId,
|
|
229
|
+
changeRequest: { id: earlier.id, number: earlier.number, url: earlier.url }
|
|
230
|
+
} satisfies Output
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const open = (yield* openChangeRequests('bots', { authors })).changeRequests
|
|
234
|
+
|
|
235
|
+
if (open.length === 0)
|
|
236
|
+
return {
|
|
237
|
+
status: 'nothing_to_sweep',
|
|
238
|
+
summary: 'The bots have nothing open.',
|
|
239
|
+
landed: [],
|
|
240
|
+
left: []
|
|
241
|
+
} satisfies Output
|
|
242
|
+
|
|
243
|
+
const assessed = yield* agentWork('assess', { agent: 'assessor', prompt: assessPrompt(open), output: Assessed })
|
|
244
|
+
|
|
245
|
+
const decisions = open.map(request => {
|
|
246
|
+
const decided = assessed.updates.find(update => update.number === request.number)
|
|
247
|
+
const placed = { number: request.number, title: request.title, url: request.url }
|
|
248
|
+
|
|
249
|
+
if (decided === undefined) return { ...placed, decision: 'hold', reason: 'It was not assessed' } as const
|
|
250
|
+
|
|
251
|
+
if (decided.major && decided.decision === 'batch')
|
|
252
|
+
return { ...placed, decision: 'alone', reason: `A major version bump goes alone. ${decided.reason}` } as const
|
|
253
|
+
|
|
254
|
+
return { ...placed, decision: decided.decision, reason: decided.reason }
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const notBatched: Placed[] = decisions.flatMap(({ decision, ...placed }) =>
|
|
258
|
+
decision === 'batch' ? [] : [placed]
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
const batch = decisions.filter(update => update.decision === 'batch')
|
|
262
|
+
|
|
263
|
+
const reported = (status: 'reported', summary: string, left: readonly Placed[]) =>
|
|
264
|
+
Effect.map(
|
|
265
|
+
artifact('report', { kind: 'sweep_report', title: 'Sweep', text: reportText([], left, summary) }),
|
|
266
|
+
stored => ({ status, summary, landed: [], left, reportArtifactId: stored.artifactId }) satisfies Output
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if (batch.length === 0) return yield* reported('reported', 'Nothing is safe to batch.', notBatched)
|
|
270
|
+
|
|
271
|
+
// The bots rebase on their own schedule: build from their heads as they are now, not as assessed.
|
|
272
|
+
const now = (yield* openChangeRequests('bots:again', { authors })).changeRequests
|
|
273
|
+
const toBatch = now.filter(request => batch.some(update => update.number === request.number))
|
|
274
|
+
|
|
275
|
+
const gone: Placed[] = batch.flatMap(update =>
|
|
276
|
+
toBatch.some(request => request.number === update.number)
|
|
277
|
+
? []
|
|
278
|
+
: [
|
|
279
|
+
{
|
|
280
|
+
number: update.number,
|
|
281
|
+
title: update.title,
|
|
282
|
+
url: update.url,
|
|
283
|
+
reason: 'It was closed or merged while Sweep ran'
|
|
284
|
+
}
|
|
285
|
+
]
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
const target = input.branch ?? 'the default branch'
|
|
289
|
+
|
|
290
|
+
let batched: Batched = yield* agentWork('batch', {
|
|
291
|
+
agent: 'implementer',
|
|
292
|
+
prompt: batchPrompt(toBatch, target),
|
|
293
|
+
output: Batched
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
let checked = yield* verify('verify', {})
|
|
297
|
+
const maxFixRounds = input.maxFixRounds ?? 2
|
|
298
|
+
const dropped = new Map(batched.dropped.map(drop => [drop.number, drop.reason]))
|
|
299
|
+
|
|
300
|
+
for (let round = 1; !checked.passed && round <= maxFixRounds && batched.applied.length > 0; round++) {
|
|
301
|
+
batched = yield* agentWork(`fix:${round}`, {
|
|
302
|
+
agent: 'implementer',
|
|
303
|
+
prompt: fixPrompt(checked, batched.applied),
|
|
304
|
+
output: Batched
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
for (const drop of batched.dropped) dropped.set(drop.number, drop.reason)
|
|
308
|
+
checked = yield* verify(`verify:${round}`, {})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const titled = (number: string) => toBatch.find(request => request.number === number)
|
|
312
|
+
|
|
313
|
+
const droppedOut: Placed[] = toBatch.flatMap(request => {
|
|
314
|
+
const reason =
|
|
315
|
+
dropped.get(request.number) ??
|
|
316
|
+
(batched.applied.includes(request.number) ? undefined : 'It was not brought onto the branch')
|
|
317
|
+
|
|
318
|
+
return reason === undefined ? [] : [{ number: request.number, title: request.title, url: request.url, reason }]
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const left = [...notBatched, ...gone, ...droppedOut]
|
|
322
|
+
|
|
323
|
+
const landing: Landing[] = batched.applied.flatMap(number => {
|
|
324
|
+
const request = titled(number)
|
|
325
|
+
|
|
326
|
+
return request === undefined
|
|
327
|
+
? []
|
|
328
|
+
: [{ number, title: request.title, url: request.url, head: request.head, reason: 'Verified in the batch' }]
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
if (landing.length === 0) return yield* reported('reported', 'No update survived the batch.', left)
|
|
332
|
+
|
|
333
|
+
if (!checked.passed) {
|
|
334
|
+
const failing = checked.checks.filter(check => check.status === 'failed').map(check => check.name)
|
|
335
|
+
|
|
336
|
+
return yield* reported(
|
|
337
|
+
'reported',
|
|
338
|
+
`The batch still fails ${failing.join(', ')} after ${maxFixRounds} rounds of fixing.`,
|
|
339
|
+
[...left, ...landing.map(({ head: _head, ...update }) => ({ ...update, reason: 'The batch did not pass' }))]
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const opened = yield* changeRequest('change-request', {
|
|
344
|
+
title: `Update ${landing.length} ${landing.length === 1 ? 'dependency' : 'dependencies'}`,
|
|
345
|
+
body: [
|
|
346
|
+
batched.summary,
|
|
347
|
+
supersedes,
|
|
348
|
+
...landing.map(supersededLine),
|
|
349
|
+
...(left.length === 0
|
|
350
|
+
? []
|
|
351
|
+
: ['Left for a person:', ...left.map(update => `- !${update.number} ${update.title}: ${update.reason}`)])
|
|
352
|
+
].join('\n\n')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const landed = yield* call('land', shepherd, {
|
|
356
|
+
changeRequestId: opened.id,
|
|
357
|
+
merge: true,
|
|
358
|
+
...(input.branch === undefined ? undefined : { branch: input.branch })
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const merged = landed.status === 'merged'
|
|
362
|
+
const closed = afterClosing(landing, left, merged ? yield* closeSuperseded(landing, opened.url) : [])
|
|
363
|
+
|
|
364
|
+
const stored = yield* artifact('report', {
|
|
365
|
+
kind: 'sweep_report',
|
|
366
|
+
title: 'Sweep',
|
|
367
|
+
text: reportText(closed.landed, closed.left, `${opened.url}: ${landed.status}. ${landed.reason}`)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const settled = { ...closed, reportArtifactId: stored.artifactId, changeRequest: opened }
|
|
371
|
+
|
|
372
|
+
if (!merged)
|
|
373
|
+
return {
|
|
374
|
+
status: 'opened',
|
|
375
|
+
summary: `${opened.url} is ${landed.status}: ${landed.reason}`,
|
|
376
|
+
...settled
|
|
377
|
+
} satisfies Output
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
status: 'landed',
|
|
381
|
+
summary: `Landed ${closed.landed.length} in ${opened.url}`,
|
|
382
|
+
...settled
|
|
383
|
+
} satisfies Output
|
|
384
|
+
})
|
|
385
|
+
})
|
package/src/tidy.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentWork,
|
|
3
|
+
artifact,
|
|
4
|
+
ask,
|
|
5
|
+
call,
|
|
6
|
+
changeRequest,
|
|
7
|
+
ChangeRequestResult,
|
|
8
|
+
defineLoop,
|
|
9
|
+
evidenceGate,
|
|
10
|
+
RunFile,
|
|
11
|
+
rallyStop,
|
|
12
|
+
settleRound,
|
|
13
|
+
syncBase,
|
|
14
|
+
verify
|
|
15
|
+
} from '@kitman-sh/sdk/loop'
|
|
16
|
+
import * as Effect from 'effect/Effect'
|
|
17
|
+
import * as Schema from 'effect/Schema'
|
|
18
|
+
import { changeRequestBody, unsettledItems } from './change-request-body'
|
|
19
|
+
import plan from './plan'
|
|
20
|
+
import review from './review'
|
|
21
|
+
|
|
22
|
+
const Input = Schema.Struct({
|
|
23
|
+
/**
|
|
24
|
+
* The head of a change to tidy, such as a Build's: Tidy then touches only that change's area and opens at most one
|
|
25
|
+
* ChangeRequest, unasked. Left out, Tidy surveys and asks what next.
|
|
26
|
+
*/
|
|
27
|
+
revision: Schema.optional(Schema.String),
|
|
28
|
+
/** The branch the ChangeRequest targets; the repository's default branch when left out. */
|
|
29
|
+
branch: Schema.optional(Schema.String),
|
|
30
|
+
repoBindingId: Schema.optional(Schema.String),
|
|
31
|
+
/** Where to look, in a few words. */
|
|
32
|
+
words: Schema.optional(Schema.String)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const Hint = Schema.Literals(['architecture', 'simplification'])
|
|
36
|
+
|
|
37
|
+
const Candidate = Schema.Struct({
|
|
38
|
+
title: Schema.String,
|
|
39
|
+
/** What to change, in words a tidy or a plan starts from. */
|
|
40
|
+
words: Schema.String,
|
|
41
|
+
hint: Hint,
|
|
42
|
+
/** Why it is worth a reviewer's time. */
|
|
43
|
+
worth: Schema.String
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const Survey = Schema.Struct({
|
|
47
|
+
/** The report, as Markdown. */
|
|
48
|
+
report: Schema.String,
|
|
49
|
+
/** The skill's HTML report, attached to the Run. */
|
|
50
|
+
htmlReport: Schema.optional(RunFile),
|
|
51
|
+
/** The improvements worth making, most valuable first; none when nothing is worth it. */
|
|
52
|
+
candidates: Schema.Array(Candidate),
|
|
53
|
+
/** Only what the code cannot tell, such as intent or plans; at most three. */
|
|
54
|
+
questions: Schema.Array(
|
|
55
|
+
Schema.Struct({
|
|
56
|
+
question: Schema.String,
|
|
57
|
+
/** The answer the surveyor would assume. */
|
|
58
|
+
recommended: Schema.String,
|
|
59
|
+
/** Why, in one or two sentences. */
|
|
60
|
+
why: Schema.String
|
|
61
|
+
})
|
|
62
|
+
).check(Schema.isMaxLength(3))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const Tidied = Schema.Struct({
|
|
66
|
+
/** Whether it changed anything; false is a good outcome when nothing is worth it. */
|
|
67
|
+
improved: Schema.Boolean,
|
|
68
|
+
hint: Schema.optional(Hint),
|
|
69
|
+
title: Schema.optional(Schema.String),
|
|
70
|
+
summary: Schema.String,
|
|
71
|
+
claims: Schema.Array(Schema.String)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const Output = Schema.Struct({
|
|
75
|
+
status: Schema.Literals(['nothing_worth_it', 'reported', 'planned', 'opened']),
|
|
76
|
+
summary: Schema.String,
|
|
77
|
+
reportArtifactId: Schema.optional(Schema.String),
|
|
78
|
+
changeRequest: Schema.optional(ChangeRequestResult),
|
|
79
|
+
settled: Schema.optional(Schema.Boolean),
|
|
80
|
+
unsettled: Schema.optional(Schema.Array(Schema.String))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
type Output = typeof Output.Type
|
|
84
|
+
|
|
85
|
+
const hints = [
|
|
86
|
+
'Hints, not a sequence; pick what is worth most:',
|
|
87
|
+
'- architecture: deepen a shallow module, close a seam that leaks, or move behaviour to the data it uses; apply the deletion test (would deleting it make things simpler?).',
|
|
88
|
+
'- simplification: duplication, dead code, needless indirection or abstraction.'
|
|
89
|
+
].join('\n')
|
|
90
|
+
|
|
91
|
+
const surveyPrompt = (words: string | undefined, answers: readonly string[]) =>
|
|
92
|
+
[
|
|
93
|
+
`Survey ${words === undefined ? 'the repository' : `this area: ${words}`} for the code-quality improvements worth a reviewer's time.`,
|
|
94
|
+
'You run the improve-codebase-architecture skill as a Kitman Step. Write its HTML report, then keep it on the Run with attach_file and cite it as htmlReport instead of opening it. Stop after the report: Kitman asks the person which candidate to take, so do not grill here.',
|
|
95
|
+
hints,
|
|
96
|
+
'List the candidates most valuable first, and none when nothing is worth it; a small or cosmetic change is not worth it.',
|
|
97
|
+
answers.length === 0
|
|
98
|
+
? 'Ask only what the code cannot tell you, such as intent or plans, and at most three questions. Keep each to the context and the question, and give the answer you would assume and why in one or two sentences.'
|
|
99
|
+
: `Your questions were answered:\n${answers.join('\n')}\nAsk nothing more.`,
|
|
100
|
+
'Change nothing. Return a report in Markdown.'
|
|
101
|
+
].join('\n\n')
|
|
102
|
+
|
|
103
|
+
const tidyPrompt = (scope: string, words: string | undefined) =>
|
|
104
|
+
[
|
|
105
|
+
`Make the one code-quality improvement worth most ${scope}.`,
|
|
106
|
+
...(words === undefined ? [] : [`Look at: ${words}`]),
|
|
107
|
+
hints,
|
|
108
|
+
'Keep behaviour exactly as it is, and keep the change small enough to review in one sitting. If nothing is worth changing, change nothing and set improved to false: that is a good outcome.',
|
|
109
|
+
'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.'
|
|
110
|
+
].join('\n\n')
|
|
111
|
+
|
|
112
|
+
/** A Build's area: what its change touched, and the files that change with them. */
|
|
113
|
+
const areaOf = (revision: string, branch: string | undefined) =>
|
|
114
|
+
`in the area of the change at ${revision}: only the files that \`git diff $(git merge-base origin/${branch ?? 'HEAD'} ${revision}) ${revision}\` touches, and the files that change with them`
|
|
115
|
+
|
|
116
|
+
const next = ['Plan it', 'Tidy the top one now', 'Done'] as const
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Finds the code-quality improvement worth most and makes it reviewable in one sitting, or says nothing is worth it.
|
|
120
|
+
* Given a change's head, such as from a Build's offer, it tidies that change's area once, verifies base and head,
|
|
121
|
+
* has it reviewed once for preserved behaviour, and opens one ChangeRequest. Otherwise it surveys, asking only what
|
|
122
|
+
* the code cannot tell, reports, and asks whether to plan it, tidy the top one now, or stop.
|
|
123
|
+
*/
|
|
124
|
+
export default defineLoop({
|
|
125
|
+
id: 'tidy',
|
|
126
|
+
version: '1.0.0',
|
|
127
|
+
title: 'Tidy',
|
|
128
|
+
input: Input,
|
|
129
|
+
output: Output,
|
|
130
|
+
agents: { implementer: 'implementer', surveyor: 'surveyor' },
|
|
131
|
+
checkout: 'write',
|
|
132
|
+
capabilities: ['agent_work', 'ask', 'verify', 'change_request'],
|
|
133
|
+
calls: () => [review, plan],
|
|
134
|
+
run: input =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const start = input.revision ?? `origin/${input.branch ?? 'HEAD'}`
|
|
137
|
+
|
|
138
|
+
/** One improvement, verified on base and head and reviewed once, so the reviewed diff is the one that ships. */
|
|
139
|
+
const tidy = (scope: string, words: string | undefined) =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
const improvement = yield* agentWork('tidy', {
|
|
142
|
+
agent: 'implementer',
|
|
143
|
+
prompt: tidyPrompt(scope, words),
|
|
144
|
+
output: Tidied
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
if (!improvement.improved)
|
|
148
|
+
return { status: 'nothing_worth_it', summary: improvement.summary } satisfies Output
|
|
149
|
+
|
|
150
|
+
const base = yield* verify('verify:base', { revision: start })
|
|
151
|
+
const head = yield* verify('verify', {})
|
|
152
|
+
|
|
153
|
+
const reviewed = yield* call('review', review, {
|
|
154
|
+
base: start,
|
|
155
|
+
head: head.revision,
|
|
156
|
+
outcome: 'refactor',
|
|
157
|
+
claims: improvement.claims
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const settled = settleRound({ findings: reviewed.findings, rebuttals: [], verdicts: reviewed.verdicts })
|
|
161
|
+
const decision = rallyStop({ round: 1, maxRounds: 1, settled })
|
|
162
|
+
const verdict = evidenceGate({ outcome: 'refactor', base, head })
|
|
163
|
+
|
|
164
|
+
const unsettled = [
|
|
165
|
+
...(verdict.settled ? [] : verdict.reasons),
|
|
166
|
+
...(decision.stop ? unsettledItems(settled, decision) : [])
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
const opened = yield* changeRequest('change-request', {
|
|
170
|
+
title: improvement.title ?? 'Tidy',
|
|
171
|
+
body: changeRequestBody({
|
|
172
|
+
summary: improvement.summary,
|
|
173
|
+
head,
|
|
174
|
+
claims: improvement.claims,
|
|
175
|
+
unsettled,
|
|
176
|
+
rounds: 1,
|
|
177
|
+
stopped: decision.stop ? decision.reason : 'round_cap'
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
status: 'opened',
|
|
183
|
+
summary: improvement.summary,
|
|
184
|
+
changeRequest: opened,
|
|
185
|
+
settled: unsettled.length === 0,
|
|
186
|
+
unsettled
|
|
187
|
+
} satisfies Output
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (input.revision !== undefined) return yield* tidy(areaOf(input.revision, input.branch), input.words)
|
|
191
|
+
|
|
192
|
+
let survey = yield* agentWork('survey', {
|
|
193
|
+
agent: 'surveyor',
|
|
194
|
+
skill: 'improve-codebase-architecture',
|
|
195
|
+
prompt: surveyPrompt(input.words, []),
|
|
196
|
+
output: Survey
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (survey.questions.length > 0) {
|
|
200
|
+
const answers = yield* Effect.forEach(survey.questions, (question, index) =>
|
|
201
|
+
Effect.map(
|
|
202
|
+
ask(`question:${index + 1}`, {
|
|
203
|
+
prompt: question.question,
|
|
204
|
+
freeText: true,
|
|
205
|
+
recommended: question.recommended,
|
|
206
|
+
recommendationReason: question.why,
|
|
207
|
+
default: 'No answer; go by the code.',
|
|
208
|
+
timeout: '1 day'
|
|
209
|
+
}),
|
|
210
|
+
answer => `- ${question.question} ${answer}`
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
survey = yield* agentWork('survey:answered', {
|
|
215
|
+
agent: 'surveyor',
|
|
216
|
+
skill: 'improve-codebase-architecture',
|
|
217
|
+
prompt: surveyPrompt(input.words, answers),
|
|
218
|
+
output: Survey
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const stored = yield* artifact('report', {
|
|
223
|
+
kind: 'tidy_report',
|
|
224
|
+
title: 'Tidy report',
|
|
225
|
+
text: survey.report,
|
|
226
|
+
...(survey.htmlReport === undefined ? undefined : { files: [survey.htmlReport] })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const [top] = survey.candidates
|
|
230
|
+
|
|
231
|
+
if (top === undefined)
|
|
232
|
+
return {
|
|
233
|
+
status: 'nothing_worth_it',
|
|
234
|
+
summary: 'Nothing here is worth a reviewer’s time.',
|
|
235
|
+
reportArtifactId: stored.artifactId
|
|
236
|
+
} satisfies Output
|
|
237
|
+
|
|
238
|
+
const answer = yield* ask('next', {
|
|
239
|
+
prompt: `Tidy found ${survey.candidates.length} worth doing, top: ${top.title}. ${top.worth}\n\nWhat next?`,
|
|
240
|
+
options: [
|
|
241
|
+
{ value: 'Plan it', description: 'Plan files the candidates as Tasks.' },
|
|
242
|
+
{ value: 'Tidy the top one now', description: 'An agent makes the top one and opens a ChangeRequest.' },
|
|
243
|
+
{ value: 'Done', description: 'Keep the report and stop.' }
|
|
244
|
+
] satisfies { readonly value: (typeof next)[number]; readonly description: string }[],
|
|
245
|
+
recommended: 'Tidy the top one now',
|
|
246
|
+
recommendationReason: `The top one is worth a reviewer's time on its own: ${top.worth}`,
|
|
247
|
+
default: 'Done',
|
|
248
|
+
timeout: '1 day'
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const reported = { summary: `Top: ${top.title}`, reportArtifactId: stored.artifactId }
|
|
252
|
+
|
|
253
|
+
if (answer === 'Done') return { status: 'reported', ...reported } satisfies Output
|
|
254
|
+
|
|
255
|
+
if (answer === 'Plan it') {
|
|
256
|
+
yield* call('plan', plan, {
|
|
257
|
+
words: [
|
|
258
|
+
'Plan these code-quality improvements:',
|
|
259
|
+
...survey.candidates.map(candidate => `- ${candidate.title}: ${candidate.words}`),
|
|
260
|
+
survey.report
|
|
261
|
+
].join('\n\n'),
|
|
262
|
+
...(input.repoBindingId === undefined ? undefined : { repoBindingId: input.repoBindingId }),
|
|
263
|
+
...(input.branch === undefined ? undefined : { branch: input.branch })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
return { status: 'planned', ...reported } satisfies Output
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The answer can come a day after the survey: tidy on the base as it is now.
|
|
270
|
+
const synced = yield* syncBase('sync', {
|
|
271
|
+
agent: 'implementer',
|
|
272
|
+
...(input.branch === undefined ? undefined : { target: input.branch })
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
if (synced.status === 'conflict')
|
|
276
|
+
return {
|
|
277
|
+
status: 'reported',
|
|
278
|
+
...reported,
|
|
279
|
+
summary: `Top: ${top.title}. Bringing the branch up to date conflicts on ${synced.files.join(', ')}`
|
|
280
|
+
} satisfies Output
|
|
281
|
+
|
|
282
|
+
const tidied = yield* tidy('', `${top.title}: ${top.words}`)
|
|
283
|
+
|
|
284
|
+
return { ...tidied, reportArtifactId: stored.artifactId } satisfies Output
|
|
285
|
+
})
|
|
286
|
+
})
|
package/tsconfig.json
ADDED
package/README.md
DELETED