@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/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import build from './build'
|
|
2
|
+
import errand from './errand'
|
|
3
|
+
import fileTasks from './file-tasks'
|
|
4
|
+
import hillclimb from './hillclimb'
|
|
5
|
+
import firewatch from './firewatch'
|
|
6
|
+
import plan from './plan'
|
|
7
|
+
import projectUpdate from './project-update'
|
|
8
|
+
import release from './release'
|
|
9
|
+
import research from './research'
|
|
10
|
+
import hindsight from './hindsight'
|
|
11
|
+
import review from './review'
|
|
12
|
+
import shepherd from './shepherd'
|
|
13
|
+
import sweep from './sweep'
|
|
14
|
+
import tidy from './tidy'
|
|
15
|
+
|
|
16
|
+
/** The Kit's Loops, by id; a fork spreads the one it replaces. */
|
|
17
|
+
export const loops = {
|
|
18
|
+
build,
|
|
19
|
+
errand,
|
|
20
|
+
'file-tasks': fileTasks,
|
|
21
|
+
hillclimb,
|
|
22
|
+
firewatch,
|
|
23
|
+
plan,
|
|
24
|
+
'project-update': projectUpdate,
|
|
25
|
+
release,
|
|
26
|
+
research,
|
|
27
|
+
hindsight,
|
|
28
|
+
review,
|
|
29
|
+
shepherd,
|
|
30
|
+
sweep,
|
|
31
|
+
tidy
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
build,
|
|
36
|
+
errand,
|
|
37
|
+
fileTasks,
|
|
38
|
+
hillclimb,
|
|
39
|
+
firewatch,
|
|
40
|
+
plan,
|
|
41
|
+
projectUpdate,
|
|
42
|
+
release,
|
|
43
|
+
research,
|
|
44
|
+
hindsight,
|
|
45
|
+
review,
|
|
46
|
+
shepherd,
|
|
47
|
+
sweep,
|
|
48
|
+
tidy
|
|
49
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentWork,
|
|
3
|
+
agentWorkOnce,
|
|
4
|
+
artifact,
|
|
5
|
+
ask,
|
|
6
|
+
call,
|
|
7
|
+
changeRequest,
|
|
8
|
+
ChangeRequestResult,
|
|
9
|
+
ChildFailed,
|
|
10
|
+
createTasks,
|
|
11
|
+
CreateTasksResult,
|
|
12
|
+
defineLoop,
|
|
13
|
+
integrationBranch,
|
|
14
|
+
mergeChangeRequest,
|
|
15
|
+
PlanTickets,
|
|
16
|
+
planWaves,
|
|
17
|
+
type IntegrationBranch,
|
|
18
|
+
type PlanTicket
|
|
19
|
+
} from '@kitman-sh/sdk/loop'
|
|
20
|
+
import * as Effect from 'effect/Effect'
|
|
21
|
+
import * as Schema from 'effect/Schema'
|
|
22
|
+
import build from './build'
|
|
23
|
+
|
|
24
|
+
const Input = Schema.Struct({
|
|
25
|
+
/** What to plan. */
|
|
26
|
+
words: Schema.String,
|
|
27
|
+
/** The Team's default RepoBinding when left out. */
|
|
28
|
+
repoBindingId: Schema.optional(Schema.String),
|
|
29
|
+
/** The branch the plan is delivered to; each repository's default branch when left out. */
|
|
30
|
+
branch: Schema.optional(Schema.String),
|
|
31
|
+
/** The Project the plan's Tasks land in. */
|
|
32
|
+
projectId: Schema.optional(Schema.String),
|
|
33
|
+
/** Implements the plan when nobody answers the menu; files it as Tasks otherwise. */
|
|
34
|
+
implement: Schema.optional(Schema.Boolean)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const Plan = Schema.Struct({
|
|
38
|
+
title: Schema.String,
|
|
39
|
+
/** The spec, as Markdown. */
|
|
40
|
+
spec: Schema.String,
|
|
41
|
+
tickets: PlanTickets,
|
|
42
|
+
/** What the plan leaves out; each becomes a constraint on the Builds of its tickets. */
|
|
43
|
+
outOfScope: Schema.Array(Schema.String),
|
|
44
|
+
/** Outcomes only visible after merge (adoption, a metric moving); never a ticket's criterion. */
|
|
45
|
+
afterMerge: Schema.Array(Schema.String),
|
|
46
|
+
/** The ChangeRequest for the ADR and glossary changes the planner left in the checkout; none when it left none. */
|
|
47
|
+
docs: Schema.optional(Schema.Struct({ title: Schema.String, summary: Schema.String }))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
type Plan = typeof Plan.Type
|
|
51
|
+
|
|
52
|
+
/** The criteria a ticket names that the base branch already meets. */
|
|
53
|
+
const CriteriaCheck = Schema.Struct({
|
|
54
|
+
alreadyMet: Schema.Array(Schema.Struct({ key: Schema.String, criterion: Schema.String }))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const Rejected = Schema.Struct({ key: Schema.String, reason: Schema.String })
|
|
58
|
+
|
|
59
|
+
const Opinion = Schema.Struct({ summary: Schema.String, concerns: Schema.Array(Schema.String) })
|
|
60
|
+
|
|
61
|
+
const TicketResult = Schema.Struct({
|
|
62
|
+
key: Schema.String,
|
|
63
|
+
title: Schema.String,
|
|
64
|
+
status: Schema.Literals(['merged', 'failed', 'skipped']),
|
|
65
|
+
repoBindingId: Schema.optional(Schema.String),
|
|
66
|
+
changeRequest: Schema.optional(ChangeRequestResult),
|
|
67
|
+
reason: Schema.optional(Schema.String)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
type TicketResult = typeof TicketResult.Type
|
|
71
|
+
|
|
72
|
+
const Output = Schema.Struct({
|
|
73
|
+
title: Schema.String,
|
|
74
|
+
outOfScope: Schema.Array(Schema.String),
|
|
75
|
+
afterMerge: Schema.Array(Schema.String),
|
|
76
|
+
/** Tickets the base already does all of, left out of the plan. */
|
|
77
|
+
rejected: Schema.Array(Rejected),
|
|
78
|
+
/** Why the Run stopped where it did, when it was not a person's choice. */
|
|
79
|
+
stopped: Schema.optional(Schema.String),
|
|
80
|
+
implemented: Schema.Boolean,
|
|
81
|
+
tickets: Schema.Array(TicketResult),
|
|
82
|
+
/** One ChangeRequest per repository with merged work, from its integration branch into its base branch. */
|
|
83
|
+
deliveries: Schema.Array(ChangeRequestResult),
|
|
84
|
+
/** The Task made for each ticket by File as Tasks. */
|
|
85
|
+
tasks: Schema.optional(CreateTasksResult.fields.tasks),
|
|
86
|
+
/** The ChangeRequest for the ADRs and glossary the grilling changed. */
|
|
87
|
+
docs: Schema.optional(ChangeRequestResult)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const menu = ['Implement now', 'File as Tasks', 'Second opinion', 'Refine', 'Finish'] as const
|
|
91
|
+
|
|
92
|
+
type Choice = (typeof menu)[number]
|
|
93
|
+
|
|
94
|
+
/** Menu rounds before the plan is filed as Tasks for a person to pick up. */
|
|
95
|
+
const maxRounds = 6
|
|
96
|
+
|
|
97
|
+
const maxOpinionsPerPlan = 2
|
|
98
|
+
|
|
99
|
+
const menuDescriptions: Record<Choice, string> = {
|
|
100
|
+
'Implement now': 'Builds the tickets in dependency order and delivers them.',
|
|
101
|
+
'File as Tasks': 'Makes a Task of each ticket, to build later.',
|
|
102
|
+
'Second opinion': 'Another Agent reviews the plan.',
|
|
103
|
+
Refine: 'You say what to change and the planner rewrites it.',
|
|
104
|
+
Finish: 'Ends the Run with the plan as it is.'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const onConflict = ['Rebuild on the integration branch', 'Skip'] as const
|
|
108
|
+
|
|
109
|
+
const onFailure = ['Retry with a steer', 'Skip it and its dependents', 'Stop here'] as const
|
|
110
|
+
|
|
111
|
+
const ticketRules = [
|
|
112
|
+
'Each ticket is one Build: a vertical slice an agent can implement, review and deliver as one ChangeRequest. Give it a short unique key, and list in blockedBy the keys of the tickets whose code it needs; tickets never block each other in a cycle. Set repoBindingId only when it changes another repository than the plan, keep one repository per ticket, and no cycle across repositories. Set outcome to the kind of change: feature, bug, refactor, docs, migration, investigation or performance.',
|
|
113
|
+
"Each acceptance criterion is something a Build can show before its ChangeRequest: a behaviour a test or a check proves. A text search is never a criterion, nor is something the code already does. Outcomes only visible after merge go under afterMerge, not under a ticket's criteria."
|
|
114
|
+
].join('\n\n')
|
|
115
|
+
|
|
116
|
+
const planPrompt = (words: string) =>
|
|
117
|
+
[
|
|
118
|
+
`Plan this: ${words}`,
|
|
119
|
+
'You run the grill-with-docs skill as a Kitman Step. Interview the person with the Kitman MCP ask tool, one question at a time, and ask only what changes what gets built: a clear goal needs no interview. Write the ADR and glossary changes the grilling settles in the checkout, and no other files.',
|
|
120
|
+
'Then write the spec and the tickets from the goal, the code and the answers.',
|
|
121
|
+
ticketRules,
|
|
122
|
+
'List in outOfScope what the plan deliberately leaves out, one item each; each holds the Builds of its tickets.',
|
|
123
|
+
'If you left ADR or glossary changes, give docs a ChangeRequest title and a summary of the decisions; Kitman opens it. Your Output is the plan, and Kitman stores it.'
|
|
124
|
+
].join('\n\n')
|
|
125
|
+
|
|
126
|
+
const planText = (plan: Plan) =>
|
|
127
|
+
[
|
|
128
|
+
`# ${plan.title}`,
|
|
129
|
+
plan.spec,
|
|
130
|
+
...(plan.outOfScope.length === 0 ? [] : ['## Out of scope', plan.outOfScope.map(item => `- ${item}`).join('\n')]),
|
|
131
|
+
...(plan.afterMerge.length === 0 ? [] : ['## After merge', plan.afterMerge.map(item => `- ${item}`).join('\n')]),
|
|
132
|
+
'## Tickets',
|
|
133
|
+
...plan.tickets.map(ticketText)
|
|
134
|
+
].join('\n\n')
|
|
135
|
+
|
|
136
|
+
const ticketText = (ticket: PlanTicket) =>
|
|
137
|
+
[
|
|
138
|
+
`### ${ticket.key}: ${ticket.title}`,
|
|
139
|
+
ticket.body,
|
|
140
|
+
...ticket.acceptanceCriteria.map(criterion => `- [ ] ${criterion}`),
|
|
141
|
+
...(ticket.blockedBy.length === 0 ? [] : [`Blocked by: ${ticket.blockedBy.join(', ')}`])
|
|
142
|
+
].join('\n\n')
|
|
143
|
+
|
|
144
|
+
const opinionPrompt = (plan: Plan) =>
|
|
145
|
+
[
|
|
146
|
+
'Review this plan as a second opinion. Look for missing scope, tickets too big to review, wrong blockers and risks. Change nothing.',
|
|
147
|
+
planText(plan)
|
|
148
|
+
].join('\n\n')
|
|
149
|
+
|
|
150
|
+
const refinePrompt = (plan: Plan, words: string) =>
|
|
151
|
+
[`Change the plan as the user asks: ${words}`, ticketRules, planText(plan)].join('\n\n')
|
|
152
|
+
|
|
153
|
+
const criteriaPrompt = (plan: Plan) =>
|
|
154
|
+
[
|
|
155
|
+
"Check each ticket's acceptance criteria against the base branch as it is now. List under alreadyMet each criterion the code already meets, with its ticket's key and the criterion word for word. Change nothing.",
|
|
156
|
+
planText(plan)
|
|
157
|
+
].join('\n\n')
|
|
158
|
+
|
|
159
|
+
const guard = ' (guard: the base already does this)'
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Labels each criterion the base already meets as a guard, and leaves out a ticket whose every criterion it meets,
|
|
163
|
+
* with its blockers' references to it.
|
|
164
|
+
*/
|
|
165
|
+
const guarded = (plan: Plan, check: typeof CriteriaCheck.Type) => {
|
|
166
|
+
const met = (ticket: PlanTicket, criterion: string) =>
|
|
167
|
+
check.alreadyMet.some(entry => entry.key === ticket.key && entry.criterion === criterion)
|
|
168
|
+
|
|
169
|
+
const rejected = plan.tickets.flatMap(ticket =>
|
|
170
|
+
ticket.acceptanceCriteria.length > 0 && ticket.acceptanceCriteria.every(criterion => met(ticket, criterion))
|
|
171
|
+
? [{ key: ticket.key, reason: 'The base branch already meets every one of its criteria' }]
|
|
172
|
+
: []
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const gone = new Set(rejected.map(entry => entry.key))
|
|
176
|
+
|
|
177
|
+
const tickets = plan.tickets.flatMap(ticket =>
|
|
178
|
+
gone.has(ticket.key)
|
|
179
|
+
? []
|
|
180
|
+
: [
|
|
181
|
+
{
|
|
182
|
+
...ticket,
|
|
183
|
+
acceptanceCriteria: ticket.acceptanceCriteria.map(criterion =>
|
|
184
|
+
met(ticket, criterion) ? `${criterion}${guard}` : criterion
|
|
185
|
+
),
|
|
186
|
+
blockedBy: ticket.blockedBy.filter(key => !gone.has(key))
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return { plan: { ...plan, tickets }, rejected }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A plan revision whose criteria were checked against the base: a ticket with nothing left to do is rejected. */
|
|
195
|
+
const checked = (plan: Plan, revision: number) =>
|
|
196
|
+
Effect.map(
|
|
197
|
+
agentWork(`criteria-check:${revision}`, { agent: 'reviewer', prompt: criteriaPrompt(plan), output: CriteriaCheck }),
|
|
198
|
+
check => guarded(plan, check)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
/** A revision of the plan, as Run artifacts later Runs retrieve. */
|
|
202
|
+
const storePlan = (plan: Plan, suffix: string) =>
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
yield* artifact(`spec${suffix}`, {
|
|
205
|
+
kind: 'plan_spec',
|
|
206
|
+
title: plan.title,
|
|
207
|
+
text: plan.spec,
|
|
208
|
+
data: { outOfScope: [...plan.outOfScope] }
|
|
209
|
+
})
|
|
210
|
+
yield* artifact(`tickets${suffix}`, {
|
|
211
|
+
kind: 'plan_tickets',
|
|
212
|
+
title: `Tickets for ${plan.title}`,
|
|
213
|
+
text: plan.tickets.map(ticketText).join('\n\n'),
|
|
214
|
+
data: plan.tickets.map(({ repoBindingId, outcome, ...ticket }) => ({
|
|
215
|
+
...ticket,
|
|
216
|
+
acceptanceCriteria: [...ticket.acceptanceCriteria],
|
|
217
|
+
blockedBy: [...ticket.blockedBy],
|
|
218
|
+
...(repoBindingId === undefined ? undefined : { repoBindingId }),
|
|
219
|
+
...(outcome === undefined ? undefined : { outcome })
|
|
220
|
+
}))
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
/** What a refine changed, ticket by ticket, so the user sees the new revision against the last. */
|
|
225
|
+
const changelog = (before: Plan, after: Plan, words: string) => {
|
|
226
|
+
const was = new Map(before.tickets.map(ticket => [ticket.key, ticketText(ticket)]))
|
|
227
|
+
const now = new Map(after.tickets.map(ticket => [ticket.key, ticketText(ticket)]))
|
|
228
|
+
|
|
229
|
+
const lines = [
|
|
230
|
+
...after.tickets.filter(ticket => !was.has(ticket.key)).map(ticket => `- Added ${ticket.key}: ${ticket.title}`),
|
|
231
|
+
...before.tickets.filter(ticket => !now.has(ticket.key)).map(ticket => `- Removed ${ticket.key}: ${ticket.title}`),
|
|
232
|
+
...after.tickets
|
|
233
|
+
.filter(ticket => was.has(ticket.key) && was.get(ticket.key) !== now.get(ticket.key))
|
|
234
|
+
.map(ticket => `- Changed ${ticket.key}: ${ticket.title}`),
|
|
235
|
+
...(before.spec === after.spec ? [] : ['- Changed the spec']),
|
|
236
|
+
...(before.title === after.title ? [] : [`- Renamed the plan from "${before.title}"`])
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
return [`Asked: ${words}`, ...(lines.length === 0 ? ['Nothing changed.'] : lines)].join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The ticket as the Build's words, for a ticket that got no Task. */
|
|
243
|
+
const buildWords = (ticket: PlanTicket) =>
|
|
244
|
+
[
|
|
245
|
+
ticket.title,
|
|
246
|
+
ticket.body,
|
|
247
|
+
...(ticket.acceptanceCriteria.length === 0
|
|
248
|
+
? []
|
|
249
|
+
: ['Acceptance criteria:', ...ticket.acceptanceCriteria.map(criterion => `- ${criterion}`)])
|
|
250
|
+
].join('\n\n')
|
|
251
|
+
|
|
252
|
+
const settled = (ticket: PlanTicket, result: Omit<TicketResult, 'key' | 'title'>): TicketResult => ({
|
|
253
|
+
key: ticket.key,
|
|
254
|
+
title: ticket.title,
|
|
255
|
+
...result
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Builds a ticket from the integration head, then merges its ChangeRequest into the integration branch. On a
|
|
260
|
+
* conflict the user chooses to rebuild from the head as it is now, or to give the ticket up. `steer` is a person's
|
|
261
|
+
* words for a retry; `label` names its Steps apart from the first try's.
|
|
262
|
+
*/
|
|
263
|
+
const buildTicket = (
|
|
264
|
+
ticket: PlanTicket,
|
|
265
|
+
start: IntegrationBranch,
|
|
266
|
+
taskId: string | undefined,
|
|
267
|
+
retry?: { readonly label: string; readonly steer: string }
|
|
268
|
+
) =>
|
|
269
|
+
Effect.gen(function* () {
|
|
270
|
+
let head = start
|
|
271
|
+
const name = retry === undefined ? ticket.key : `${ticket.key}:${retry.label}`
|
|
272
|
+
|
|
273
|
+
for (let attempt = 1; ; attempt++) {
|
|
274
|
+
const suffix = attempt === 1 ? '' : `:${attempt}`
|
|
275
|
+
|
|
276
|
+
const words =
|
|
277
|
+
retry === undefined
|
|
278
|
+
? taskId === undefined
|
|
279
|
+
? { words: buildWords(ticket) }
|
|
280
|
+
: undefined
|
|
281
|
+
: { words: `${buildWords(ticket)}\n\nAn earlier Build of this failed. ${retry.steer}` }
|
|
282
|
+
|
|
283
|
+
const built = yield* call(`build:${name}${suffix}`, build, {
|
|
284
|
+
...words,
|
|
285
|
+
...(taskId === undefined ? undefined : { taskId }),
|
|
286
|
+
repoBindingId: head.repoBindingId,
|
|
287
|
+
branch: head.branch,
|
|
288
|
+
revision: head.revision
|
|
289
|
+
}).pipe(Effect.catchTag('ChildFailed', (failure: ChildFailed) => Effect.succeed(failure)))
|
|
290
|
+
|
|
291
|
+
if (built instanceof ChildFailed)
|
|
292
|
+
return settled(ticket, { status: 'failed', repoBindingId: head.repoBindingId, reason: built.reason })
|
|
293
|
+
|
|
294
|
+
// Only what the Build verified merges; a commit pushed to its branch since is rebuilt or skipped like a conflict.
|
|
295
|
+
const merged = yield* mergeChangeRequest(`merge:${name}${suffix}`, {
|
|
296
|
+
changeRequestId: built.changeRequest.id,
|
|
297
|
+
head: built.revision
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const delivered = { repoBindingId: head.repoBindingId, changeRequest: built.changeRequest }
|
|
301
|
+
|
|
302
|
+
if (merged.status === 'merged') return settled(ticket, { status: 'merged', ...delivered })
|
|
303
|
+
|
|
304
|
+
const choice = yield* ask(`conflict:${name}${suffix}`, {
|
|
305
|
+
prompt: `The ChangeRequest for ${ticket.key} (${built.changeRequest.url}) ${merged.status === 'moved' ? 'changed after it was built' : `conflicts with ${head.branch}`}. Rebuild ${ticket.key} on the integration branch as it is now, or skip it and the tickets it blocks?`,
|
|
306
|
+
options: [
|
|
307
|
+
{
|
|
308
|
+
value: 'Rebuild on the integration branch',
|
|
309
|
+
description: 'Builds the ticket again from the branch as it is now.'
|
|
310
|
+
},
|
|
311
|
+
{ value: 'Skip', description: 'Leaves it and the tickets it blocks undelivered.' }
|
|
312
|
+
] satisfies { readonly value: (typeof onConflict)[number]; readonly description: string }[],
|
|
313
|
+
recommended: 'Rebuild on the integration branch',
|
|
314
|
+
recommendationReason:
|
|
315
|
+
'A rebuild on the current branch usually resolves a conflict, and skipping also drops every ticket it blocks.',
|
|
316
|
+
default: 'Skip',
|
|
317
|
+
timeout: '1 hour'
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
if (choice === 'Skip')
|
|
321
|
+
return settled(ticket, { status: 'failed', ...delivered, reason: `It conflicted with ${head.branch}` })
|
|
322
|
+
|
|
323
|
+
head = yield* integrationBranch(`rebuild-from:${name}:${attempt + 1}`, {
|
|
324
|
+
repoBindingId: head.repoBindingId
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const deliveryBody = (plan: Plan, results: readonly TicketResult[]) => {
|
|
330
|
+
const line = (result: TicketResult) =>
|
|
331
|
+
result.status === 'merged' && result.changeRequest !== undefined
|
|
332
|
+
? `- **${result.key}** [${result.title}](${result.changeRequest.url}); its evidence is on that ChangeRequest`
|
|
333
|
+
: `- **${result.key}** ${result.title}: ${result.reason ?? result.status}`
|
|
334
|
+
|
|
335
|
+
const section = (title: string, status: TicketResult['status']) => {
|
|
336
|
+
const lines = results.flatMap(result => (result.status === status ? [line(result)] : []))
|
|
337
|
+
|
|
338
|
+
return lines.length === 0 ? [] : [`## ${title}`, lines.join('\n')]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return [
|
|
342
|
+
`Delivers the plan "${plan.title}".`,
|
|
343
|
+
...section('Merged', 'merged'),
|
|
344
|
+
...section('Failed', 'failed'),
|
|
345
|
+
...section('Skipped', 'skipped')
|
|
346
|
+
].join('\n\n')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
type Filed = { readonly revision: number; readonly tasks: typeof CreateTasksResult.Type.tasks }
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Makes a Task per ticket (or takes those File as Tasks made of this revision), then builds the tickets in waves on an
|
|
353
|
+
* integration branch per repository: a wave starts from the integration heads once its blockers' ChangeRequests are
|
|
354
|
+
* merged, so each Build contains its blockers' code. A failed ticket asks: retry it with a person's steer, skip it and
|
|
355
|
+
* its dependents (the default), or stop. Finally one ChangeRequest per repository delivers the integration branch.
|
|
356
|
+
*/
|
|
357
|
+
const implement = (input: typeof Input.Type, plan: Plan, revision: number, filed: Filed | undefined) =>
|
|
358
|
+
Effect.gen(function* () {
|
|
359
|
+
const tasks =
|
|
360
|
+
filed?.revision === revision
|
|
361
|
+
? filed.tasks
|
|
362
|
+
: (yield* createTasks('tasks', {
|
|
363
|
+
tickets: plan.tickets,
|
|
364
|
+
...(input.projectId === undefined ? undefined : { projectId: input.projectId })
|
|
365
|
+
})).tasks
|
|
366
|
+
|
|
367
|
+
const taskOf = new Map(tasks.map(task => [task.key, task.taskId]))
|
|
368
|
+
const results = new Map<string, TicketResult>()
|
|
369
|
+
const waves = yield* planWaves(plan.tickets)
|
|
370
|
+
let stopped: string | undefined
|
|
371
|
+
|
|
372
|
+
for (const [index, wave] of waves.entries()) {
|
|
373
|
+
const ready: PlanTicket[] = []
|
|
374
|
+
|
|
375
|
+
for (const ticket of wave) {
|
|
376
|
+
const unmerged = ticket.blockedBy.find(key => results.get(key)?.status !== 'merged')
|
|
377
|
+
|
|
378
|
+
if (stopped !== undefined) results.set(ticket.key, settled(ticket, { status: 'skipped', reason: stopped }))
|
|
379
|
+
else if (unmerged === undefined) ready.push(ticket)
|
|
380
|
+
else
|
|
381
|
+
results.set(
|
|
382
|
+
ticket.key,
|
|
383
|
+
settled(ticket, { status: 'skipped', reason: `blocked by ${unmerged}, which was not merged` })
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const repoOf = (ticket: PlanTicket) => ticket.repoBindingId ?? input.repoBindingId
|
|
388
|
+
const heads = new Map<string | undefined, IntegrationBranch>()
|
|
389
|
+
|
|
390
|
+
for (const repo of new Set(ready.map(repoOf)))
|
|
391
|
+
heads.set(
|
|
392
|
+
repo,
|
|
393
|
+
yield* integrationBranch(
|
|
394
|
+
`integration:wave-${index + 1}:${repo ?? 'default'}`,
|
|
395
|
+
repo === undefined ? {} : { repoBindingId: repo }
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
const built = yield* Effect.forEach(
|
|
400
|
+
ready.flatMap(ticket => {
|
|
401
|
+
const head = heads.get(repoOf(ticket))
|
|
402
|
+
|
|
403
|
+
return head === undefined ? [] : [buildTicket(ticket, head, taskOf.get(ticket.key))]
|
|
404
|
+
}),
|
|
405
|
+
building => building,
|
|
406
|
+
{ concurrency: 'unbounded' }
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
for (const first of built) {
|
|
410
|
+
const ticket = ready.find(entry => entry.key === first.key)
|
|
411
|
+
let result = first
|
|
412
|
+
|
|
413
|
+
for (let retry = 1; ticket !== undefined && result.status === 'failed' && stopped === undefined; retry++) {
|
|
414
|
+
const choice = yield* ask(`failed:${ticket.key}:${retry}`, {
|
|
415
|
+
prompt: `The Build of ${ticket.key} failed: ${result.reason ?? 'no reason given'}. Retry it with a steer, skip it and the tickets it blocks, or stop here?`,
|
|
416
|
+
options: onFailure,
|
|
417
|
+
recommended: 'Retry with a steer',
|
|
418
|
+
recommendationReason:
|
|
419
|
+
'The reason it failed is above, so a steer can aim the retry at it; skipping also drops the tickets it blocks.',
|
|
420
|
+
default: 'Skip it and its dependents',
|
|
421
|
+
timeout: '1 hour'
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
if (choice === 'Skip it and its dependents') break
|
|
425
|
+
|
|
426
|
+
if (choice === 'Stop here') {
|
|
427
|
+
stopped = `Stopped after ${ticket.key} failed`
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const steer = yield* ask(`steer:${ticket.key}:${retry}`, {
|
|
432
|
+
prompt: `What should the retry of ${ticket.key} do differently?`,
|
|
433
|
+
freeText: true,
|
|
434
|
+
recommended: `Address the failure: ${result.reason ?? 'find out why it failed first'}`,
|
|
435
|
+
recommendationReason: 'Pointing the retry at the reason it failed is the least a steer should do.',
|
|
436
|
+
default: null
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
const head = yield* integrationBranch(`integration:${ticket.key}:retry-${retry}`, {
|
|
440
|
+
...(repoOf(ticket) === undefined ? undefined : { repoBindingId: repoOf(ticket) })
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
result = yield* buildTicket(ticket, head, taskOf.get(ticket.key), { label: `retry-${retry}`, steer })
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
results.set(result.key, result)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const ordered = plan.tickets.flatMap(ticket => results.get(ticket.key) ?? [])
|
|
451
|
+
|
|
452
|
+
const repos = [
|
|
453
|
+
...new Set(ordered.flatMap(result => (result.status === 'merged' ? (result.repoBindingId ?? []) : [])))
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
const deliveries = yield* Effect.forEach(repos, repo =>
|
|
457
|
+
changeRequest(`deliver:${repo}`, {
|
|
458
|
+
title: plan.title,
|
|
459
|
+
body: deliveryBody(
|
|
460
|
+
plan,
|
|
461
|
+
ordered.filter(result => result.repoBindingId === repo || result.repoBindingId === undefined)
|
|
462
|
+
),
|
|
463
|
+
integration: repo
|
|
464
|
+
})
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return { tickets: ordered, deliveries, tasks, ...(stopped === undefined ? undefined : { stopped }) }
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Plans from the goal and the code: the planner runs grill-with-docs, asking only the decisions that change the
|
|
472
|
+
* tickets, and Plan opens a docs ChangeRequest for the ADR and glossary changes it left. Plan checks each ticket's
|
|
473
|
+
* criteria against the base, stores the spec and tickets as Run artifacts, and then asks what next, at most six times:
|
|
474
|
+
* build the tickets, file them as Tasks, ask another Agent's opinion (twice at most), refine the plan from the user's
|
|
475
|
+
* words, or finish (ADR-0051). Nobody answering files it as Tasks, or implements it when started with `implement`.
|
|
476
|
+
*/
|
|
477
|
+
export default defineLoop({
|
|
478
|
+
id: 'plan',
|
|
479
|
+
version: '2.0.0',
|
|
480
|
+
title: 'Plan',
|
|
481
|
+
input: Input,
|
|
482
|
+
output: Output,
|
|
483
|
+
agents: { planner: 'planner', reviewer: 'reviewer' },
|
|
484
|
+
checkout: 'write',
|
|
485
|
+
// A Child Run gets no more than its parent was granted, so this covers what each ticket's Build needs too.
|
|
486
|
+
capabilities: [
|
|
487
|
+
'agent_work',
|
|
488
|
+
'ask',
|
|
489
|
+
'call',
|
|
490
|
+
'verify',
|
|
491
|
+
'change_request',
|
|
492
|
+
'write_back',
|
|
493
|
+
'create_tasks',
|
|
494
|
+
'merge_change_request',
|
|
495
|
+
'read_change_request',
|
|
496
|
+
'post_comment'
|
|
497
|
+
],
|
|
498
|
+
calls: () => [build],
|
|
499
|
+
run: input =>
|
|
500
|
+
Effect.gen(function* () {
|
|
501
|
+
const drafted = yield* agentWorkOnce('plan', {
|
|
502
|
+
agent: 'planner',
|
|
503
|
+
skill: 'grill-with-docs',
|
|
504
|
+
prompt: planPrompt(input.words),
|
|
505
|
+
output: Plan
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
const docs =
|
|
509
|
+
drafted.docs === undefined
|
|
510
|
+
? undefined
|
|
511
|
+
: yield* changeRequest('docs', { title: drafted.docs.title, body: drafted.docs.summary })
|
|
512
|
+
|
|
513
|
+
let revision = 1
|
|
514
|
+
let { plan, rejected } = yield* checked(drafted, revision)
|
|
515
|
+
let filed: Filed | undefined
|
|
516
|
+
let opinions = 0
|
|
517
|
+
|
|
518
|
+
yield* storePlan(plan, '')
|
|
519
|
+
|
|
520
|
+
const project = input.projectId === undefined ? undefined : { projectId: input.projectId }
|
|
521
|
+
|
|
522
|
+
const ended = (rest: { readonly implemented: boolean; readonly stopped?: string }) => ({
|
|
523
|
+
title: plan.title,
|
|
524
|
+
outOfScope: plan.outOfScope,
|
|
525
|
+
afterMerge: plan.afterMerge,
|
|
526
|
+
rejected,
|
|
527
|
+
tickets: [],
|
|
528
|
+
deliveries: [],
|
|
529
|
+
...(filed === undefined ? undefined : { tasks: filed.tasks }),
|
|
530
|
+
...(docs === undefined ? undefined : { docs }),
|
|
531
|
+
...rest
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
535
|
+
const isFiled = filed?.revision === revision
|
|
536
|
+
|
|
537
|
+
const options = menu.filter(
|
|
538
|
+
option =>
|
|
539
|
+
!(option === 'File as Tasks' && isFiled) && !(option === 'Second opinion' && opinions >= maxOpinionsPerPlan)
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
const fallback: Choice = input.implement === true ? 'Implement now' : isFiled ? 'Finish' : 'File as Tasks'
|
|
543
|
+
|
|
544
|
+
const answer = yield* ask(`next:${round}`, {
|
|
545
|
+
prompt: `What next for the plan "${plan.title}"?`,
|
|
546
|
+
options: options.map(option => ({ value: option, description: menuDescriptions[option] })),
|
|
547
|
+
recommended: fallback,
|
|
548
|
+
recommendationReason:
|
|
549
|
+
fallback === 'Implement now'
|
|
550
|
+
? 'You asked for the plan to be implemented.'
|
|
551
|
+
: fallback === 'Finish'
|
|
552
|
+
? 'The plan is filed as Tasks, so nothing is lost by finishing.'
|
|
553
|
+
: 'Filing keeps the plan as Tasks a person or a Build can pick up later.',
|
|
554
|
+
default: fallback,
|
|
555
|
+
timeout: '1 hour'
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const choice = menu.find(option => option === answer) ?? fallback
|
|
559
|
+
|
|
560
|
+
switch (choice) {
|
|
561
|
+
case 'Finish':
|
|
562
|
+
return ended({ implemented: false })
|
|
563
|
+
case 'Implement now': {
|
|
564
|
+
const delivered = yield* implement(input, plan, revision, filed)
|
|
565
|
+
|
|
566
|
+
return { ...ended({ implemented: true }), ...delivered }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
case 'File as Tasks': {
|
|
570
|
+
const made = yield* createTasks(`file-tasks:${round}`, { tickets: plan.tickets, ...project })
|
|
571
|
+
|
|
572
|
+
filed = { revision, tasks: made.tasks }
|
|
573
|
+
break
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
case 'Second opinion': {
|
|
577
|
+
opinions++
|
|
578
|
+
|
|
579
|
+
const opinion = yield* agentWork(`second-opinion:${round}`, {
|
|
580
|
+
agent: 'reviewer',
|
|
581
|
+
prompt: opinionPrompt(plan),
|
|
582
|
+
output: Opinion
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
yield* artifact(`opinion:${round}`, {
|
|
586
|
+
kind: 'plan_review',
|
|
587
|
+
title: `Second opinion on ${plan.title}`,
|
|
588
|
+
text: [opinion.summary, ...opinion.concerns.map(concern => `- ${concern}`)].join('\n\n')
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
break
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
case 'Refine': {
|
|
595
|
+
const words = yield* ask(`refine:${round}`, {
|
|
596
|
+
prompt: 'What should change in the plan?',
|
|
597
|
+
freeText: true,
|
|
598
|
+
informationOnly: true,
|
|
599
|
+
recommendationReason: 'You chose to refine it, so only you know what should change.'
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
const refined = yield* agentWorkOnce(`refine-plan:${round}`, {
|
|
603
|
+
agent: 'planner',
|
|
604
|
+
prompt: refinePrompt(plan, words),
|
|
605
|
+
output: Plan
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
const before = plan
|
|
609
|
+
|
|
610
|
+
revision++
|
|
611
|
+
;({ plan, rejected } = yield* checked(refined, revision))
|
|
612
|
+
yield* storePlan(plan, `:${round}`)
|
|
613
|
+
yield* artifact(`changes:${round}`, {
|
|
614
|
+
kind: 'plan_changes',
|
|
615
|
+
title: `Revision ${revision} of ${plan.title}`,
|
|
616
|
+
text: changelog(before, plan, words)
|
|
617
|
+
})
|
|
618
|
+
break
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (filed?.revision !== revision)
|
|
624
|
+
filed = {
|
|
625
|
+
revision,
|
|
626
|
+
tasks: (yield* createTasks('file-tasks:last', { tickets: plan.tickets, ...project })).tasks
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return ended({
|
|
630
|
+
implemented: false,
|
|
631
|
+
stopped: `No decision after ${maxRounds} rounds, so the tickets are filed as Tasks for a person to pick up`
|
|
632
|
+
})
|
|
633
|
+
})
|
|
634
|
+
})
|