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

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/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
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {},
4
+ "include": ["src/**/*.ts", "kitman.config.ts"],
5
+ "exclude": ["node_modules", "dist"]
6
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.