@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/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
+ })