@alecsibilia/luca 14.0.0-alpha.3 → 14.0.0-alpha.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.4
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.3
4
8
 
5
9
  No changes in this release.
@@ -118,6 +118,9 @@ const LENS_STAGES: { stage: LensState; title: string; icon: string }[] = [
118
118
  /** A card or stage key. */
119
119
  type Key = string
120
120
 
121
+ /** The key of the fold for tickets left out for a person (#499). */
122
+ const FOR_A_PERSON: Key = 'for_a_person'
123
+
121
124
  type DotKind = 'done' | 'current' | 'todo' | 'skip' | 'stopped'
122
125
 
123
126
  const makeStyles = ({
@@ -1063,11 +1066,12 @@ export const BoardPanel = ({
1063
1066
  )
1064
1067
  const [folded, setFolded] = useState<ReadonlySet<Key>>(
1065
1068
  () =>
1066
- new Set(
1067
- TICKET_STAGES.filter((entry) => entry.folded).map(
1069
+ new Set<Key>([
1070
+ ...TICKET_STAGES.filter((entry) => entry.folded).map(
1068
1071
  (entry) => entry.stage
1069
- )
1070
- )
1072
+ ),
1073
+ FOR_A_PERSON,
1074
+ ])
1071
1075
  )
1072
1076
  const look = { theme, styles }
1073
1077
  const state = board.data?.selected ?? null
@@ -1293,6 +1297,39 @@ export const BoardPanel = ({
1293
1297
  )
1294
1298
  })}
1295
1299
 
1300
+ {state.for_a_person.length > 0 ? (
1301
+ <Section styles={styles}>
1302
+ <StageHeader
1303
+ title="For a person"
1304
+ hint="left out of this run: a person does these, or they wait on one"
1305
+ icon="User"
1306
+ count={state.for_a_person.length}
1307
+ color={theme.colors.foregroundMuted}
1308
+ folded={folded.has(FOR_A_PERSON)}
1309
+ onPress={() =>
1310
+ setFolded((set) =>
1311
+ toggled({ set, key: FOR_A_PERSON })
1312
+ )
1313
+ }
1314
+ {...look}
1315
+ />
1316
+ {folded.has(FOR_A_PERSON) ? null : (
1317
+ <View style={styles.stageBody}>
1318
+ {state.for_a_person.map((ticket) => (
1319
+ <Text
1320
+ key={ticket.number}
1321
+ style={styles.muted}
1322
+ numberOfLines={2}
1323
+ >
1324
+ #{ticket.number} {ticket.title} ·{' '}
1325
+ {ticket.why}
1326
+ </Text>
1327
+ ))}
1328
+ </View>
1329
+ )}
1330
+ </Section>
1331
+ ) : null}
1332
+
1296
1333
  {showsFinalReview({ state }) ? (
1297
1334
  <>
1298
1335
  <View style={styles.rule} />
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.3",
3
+ "version": "14.0.0-alpha.4",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -48,6 +48,26 @@ const TestRunSchema = z.looseObject({
48
48
 
49
49
  const WorktreeSchema = z.looseObject({ branch: z.string() })
50
50
 
51
+ /**
52
+ * The open tickets intake left out for a person (#499): `for_a_person`, or
53
+ * `waits_on_person` with the tickets for a person it `waits_on`, and the
54
+ * blockers it waits `through`. Missing in older journals.
55
+ */
56
+ const LeftOutListSchema = z
57
+ .array(
58
+ z.looseObject({
59
+ number: z.number().int(),
60
+ title: z.string().catch(''),
61
+ reason: z.enum(['for_a_person', 'waits_on_person']),
62
+ waits_on: z.array(z.number().int()).catch([]).default([]),
63
+ through: z.array(z.number().int()).catch([]).default([]),
64
+ })
65
+ )
66
+ .catch([])
67
+ .default([])
68
+
69
+ export type LeftOutList = z.infer<typeof LeftOutListSchema>
70
+
51
71
  const tokenCount = z.number().min(0).catch(0)
52
72
 
53
73
  /** One plan window's reading: `utilization` 0 to 1, `resetsAt` in seconds. */
@@ -128,13 +148,19 @@ export const BOARD_VOCABULARY = {
128
148
  })
129
149
  ),
130
150
  }),
131
- /** The run ends with nothing to do; `already_done` is empty at intake (#484). */
151
+ /**
152
+ * The run ends with nothing to do; `already_done` is empty at intake
153
+ * (#484), and `left_out` names the tickets left for a person (#499).
154
+ */
132
155
  nothing_to_do: z.looseObject({
133
156
  already_done: z.array(z.number().int()).catch([]).default([]),
157
+ left_out: LeftOutListSchema,
134
158
  }),
135
159
  spec_snapshot: z.looseObject({
136
160
  spec: z.looseObject({ number: z.number(), title: z.string() }),
137
161
  ticket_order: z.array(z.number()).catch([]),
162
+ /** The tickets left out for a person (#499); older journals have none. */
163
+ left_out: LeftOutListSchema,
138
164
  }),
139
165
  ticket_snapshot: z.looseObject({
140
166
  number: z.number().int(),
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.3"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.4"
@@ -5,6 +5,7 @@ import {
5
5
  failureText,
6
6
  finalReasonText,
7
7
  LEARNER,
8
+ numbersText,
8
9
  reasonText,
9
10
  RETRY_REFUSED_TEXT,
10
11
  rebasedText,
@@ -186,6 +187,44 @@ const ignoredText = ({
186
187
  }
187
188
  }
188
189
 
190
+ /**
191
+ * Why a run ended with nothing to do: its tickets were already done (#484),
192
+ * or what is open is for a person or waits on one (#499), or the spec has
193
+ * no open tickets.
194
+ */
195
+ const nothingToDoText = ({
196
+ content,
197
+ }: {
198
+ content: Extract<BoardRecord, { kind: 'nothing_to_do' }>['content']
199
+ }): string => {
200
+ const numbersOf = (reason: 'for_a_person' | 'waits_on_person') =>
201
+ content.left_out
202
+ .filter((ticket) => ticket.reason === reason)
203
+ .map(({ number }) => number)
204
+ const people = numbersOf('for_a_person')
205
+ const waiting = numbersOf('waits_on_person')
206
+ const done = content.already_done
207
+ const parts = [
208
+ done.length === 0
209
+ ? ''
210
+ : `the work of ${done.map((number) => `#${number}`).join(', ')} was already on the base branch`,
211
+ people.length === 0
212
+ ? ''
213
+ : `${numbersText({ numbers: people })} ${people.length === 1 ? 'is' : 'are'} for a person`,
214
+ waiting.length === 0
215
+ ? ''
216
+ : `${numbersText({ numbers: waiting })} ${waiting.length === 1 ? 'waits' : 'wait'} on a ticket for a person`,
217
+ ].filter((part) => part !== '')
218
+ if (parts.length === 0)
219
+ return 'Nothing to do: the spec has no open tickets.'
220
+ const last = parts.at(-1) ?? ''
221
+ const text =
222
+ parts.length === 1
223
+ ? last
224
+ : `${parts.slice(0, -1).join(', ')}, and ${last}`
225
+ return `Nothing to do: ${text}.`
226
+ }
227
+
189
228
  /**
190
229
  * What a record means in one line, or `null` for records that are noise in
191
230
  * a chat (snapshots, session summaries, a clean leftover scan, Jev in shadow
@@ -218,21 +257,22 @@ export const describeRecord = ({
218
257
  text: `Intake refused the run. ${after.run.refusal.join('; ')}`,
219
258
  tone: 'danger',
220
259
  })
221
- case 'nothing_to_do': {
222
- const done = record.content.already_done
260
+ case 'nothing_to_do':
223
261
  return event({
224
- text:
225
- done.length === 0
226
- ? 'Nothing to do: the spec has no open tickets.'
227
- : `Nothing to do: the work of ${done.map((number) => `#${number}`).join(', ')} was already on the base branch.`,
262
+ text: nothingToDoText({ content: record.content }),
228
263
  tone: 'info',
229
264
  })
230
- }
231
- case 'spec_snapshot':
265
+ case 'spec_snapshot': {
266
+ const { spec, ticket_order, left_out } = record.content
267
+ const people =
268
+ left_out.length === 0
269
+ ? ''
270
+ : ` ${left_out.length} left for a person: ${left_out.map(({ number }) => `#${number}`).join(', ')}.`
232
271
  return event({
233
- text: `Intake passed: spec #${record.content.spec.number}, ${record.content.ticket_order.length} tickets.`,
272
+ text: `Intake passed: spec #${spec.number}, ${ticket_order.length} tickets.${people}`,
234
273
  tone: 'success',
235
274
  })
275
+ }
236
276
  case 'run_branch_created':
237
277
  return event({
238
278
  text: `The run branch ${record.content.branch} is ready.`,
@@ -1,4 +1,4 @@
1
- import type { BoardRecord } from './board-vocabulary'
1
+ import type { BoardRecord, LeftOutList } from './board-vocabulary'
2
2
 
3
3
  import {
4
4
  ALL_STEPS_DONE,
@@ -16,6 +16,7 @@ import {
16
16
  type FindingCounts,
17
17
  type LensCard,
18
18
  type NeedsYou,
19
+ type PersonTicket,
19
20
  type PlanUsed,
20
21
  type RunInfo,
21
22
  type RunStatus,
@@ -208,6 +209,7 @@ export const createBoardState = ({
208
209
  started_on: null,
209
210
  version_note: null,
210
211
  },
212
+ for_a_person: [],
211
213
  usage: null,
212
214
  usage_label: USAGE_LABEL,
213
215
  run_tokens: 0,
@@ -236,6 +238,47 @@ export const createBoardState = ({
236
238
  latest: null,
237
239
  })
238
240
 
241
+ /** Issue numbers as `#12`, `#12 and #13`, or `#12, #13 and #14`. */
242
+ export const numbersText = ({ numbers }: { numbers: number[] }): string => {
243
+ const refs = numbers.map((number) => `#${number}`)
244
+ const last = refs.at(-1) ?? ''
245
+ return refs.length <= 1
246
+ ? last
247
+ : `${refs.slice(0, -1).join(', ')} and ${last}`
248
+ }
249
+
250
+ /**
251
+ * The tickets left out for a person (#499), with why in words, as the
252
+ * engine's PR says it.
253
+ *
254
+ * @example
255
+ * personTickets({ left_out: [{ number: 13, title: 'Link', reason: 'waits_on_person', waits_on: [12], through: [] }] })
256
+ * // [{ number: 13, title: 'Link', why: 'waits on #12, which is for a person' }]
257
+ */
258
+ const personTickets = ({
259
+ left_out,
260
+ }: {
261
+ left_out: LeftOutList
262
+ }): PersonTicket[] =>
263
+ left_out.map(({ number, title, reason, waits_on, through }) => {
264
+ if (reason === 'for_a_person') {
265
+ return { number, title, why: 'for a person' }
266
+ }
267
+ const which =
268
+ waits_on.length === 1
269
+ ? 'which is for a person'
270
+ : 'which are for a person'
271
+ const via =
272
+ through.length === 0
273
+ ? ''
274
+ : ` (through ${numbersText({ numbers: through })})`
275
+ return {
276
+ number,
277
+ title,
278
+ why: `waits on ${numbersText({ numbers: waits_on })}, ${which}${via}`,
279
+ }
280
+ })
281
+
239
282
  const newTicket = ({
240
283
  number,
241
284
  title,
@@ -842,10 +885,19 @@ const applyKind = ({
842
885
  },
843
886
  }
844
887
  case 'nothing_to_do':
845
- return { ...state, run: { ...state.run, phase: 'nothing_to_do' } }
888
+ return {
889
+ ...state,
890
+ for_a_person: personTickets({
891
+ left_out: record.content.left_out,
892
+ }),
893
+ run: { ...state.run, phase: 'nothing_to_do' },
894
+ }
846
895
  case 'spec_snapshot':
847
896
  return {
848
897
  ...state,
898
+ for_a_person: personTickets({
899
+ left_out: record.content.left_out,
900
+ }),
849
901
  run: {
850
902
  ...state.run,
851
903
  spec_number: record.content.spec.number,
@@ -544,8 +544,27 @@ export const NO_MEMORY: MemoryCounts = {
544
544
  failed: 0,
545
545
  }
546
546
 
547
+ /**
548
+ * An open ticket the run left out for a person (#499): one labeled
549
+ * `ready-for-human`, or one waiting on such a ticket. Not a problem, so the
550
+ * board lists it calmly and nobody is asked to act.
551
+ */
552
+ export const PersonTicketSchema = z.object({
553
+ number: z.number().int(),
554
+ title: z.string(),
555
+ /** Why, in words: `for a person`, or `waits on #12, which is for a person`. */
556
+ why: z.string(),
557
+ })
558
+
559
+ export type PersonTicket = z.infer<typeof PersonTicketSchema>
560
+
547
561
  export const BoardStateSchema = z.object({
548
562
  run: RunInfoSchema,
563
+ /**
564
+ * The open tickets left out for a person (#499), by number. Defaulted,
565
+ * so an older board state still parses.
566
+ */
567
+ for_a_person: z.array(PersonTicketSchema).default([]),
549
568
  usage: UsageSchema.nullable(),
550
569
  /** What `usage` is, in words. Defaulted, so an older board state still parses. */
551
570
  usage_label: z.string().default(USAGE_LABEL),
@@ -18,6 +18,7 @@ import {
18
18
  startRun,
19
19
  STOP_ACTIONS,
20
20
  } from '../core/execute'
21
+ import { nothingToDoText } from '../core/left-out'
21
22
  import { createGitAdapter } from '../git/git-adapter'
22
23
  import { createTypeSafeJev } from '../jev/jev-client'
23
24
  import type { JevShadow } from '../jev/jev-shadow'
@@ -50,8 +51,17 @@ export const DEMO_TURN_DELAY_MS = 1500
50
51
  const errorText = (error: unknown): string =>
51
52
  error instanceof Error ? error.message : String(error)
52
53
 
53
- /** What a run's last action means for the person watching it. */
54
- const endOf = ({ action }: { action: EngineAction }): RunEnd => {
54
+ /**
55
+ * What a run's last action means for the person watching it. A run with
56
+ * nothing to do says why, from its `nothing_to_do` record in `records`.
57
+ */
58
+ const endOf = ({
59
+ action,
60
+ records,
61
+ }: {
62
+ action: EngineAction
63
+ records: JournalRecord[]
64
+ }): RunEnd => {
55
65
  if (action.type === 'invalid_journal') {
56
66
  return {
57
67
  ok: false,
@@ -70,11 +80,24 @@ const endOf = ({ action }: { action: EngineAction }): RunEnd => {
70
80
  ok: true,
71
81
  message: `PR opened: ${action.pull_request.url}`,
72
82
  }
73
- case 'nothing_to_do':
83
+ case 'nothing_to_do': {
84
+ const end = records.findLast(
85
+ (record) => record.kind === 'nothing_to_do'
86
+ )
74
87
  return {
75
88
  ok: true,
76
- message: 'Nothing to do: every ticket of the spec is closed.',
89
+ message: nothingToDoText({
90
+ content:
91
+ end?.kind === 'nothing_to_do'
92
+ ? end.content
93
+ : {
94
+ closed_tickets: [],
95
+ already_done: [],
96
+ left_out: [],
97
+ },
98
+ }),
77
99
  }
100
+ }
78
101
  case 'refused':
79
102
  return {
80
103
  ok: false,
@@ -157,7 +180,8 @@ const driveRun = async ({
157
180
  }): Promise<RunEnd> => {
158
181
  let end: RunEnd
159
182
  try {
160
- end = endOf({ action: await run() })
183
+ const action = await run()
184
+ end = endOf({ action, records: journal.read() })
161
185
  } catch (error) {
162
186
  const message = errorText(error)
163
187
  // A launcher stop is journaled as run_stopped, then thrown.
@@ -25,10 +25,9 @@ import {
25
25
  import { safeMemory, type MemoryClient } from '../memory/memory-client'
26
26
  import { runCommand } from '../shell/run-command'
27
27
  import {
28
- NEEDS_INFO_LABEL,
29
- READY_LABEL,
30
- REFACTOR_LABEL,
28
+ RELEASE_LABEL_DEFINITIONS,
31
29
  RELEASE_LABELS,
30
+ RUN_LABELS,
32
31
  type Tracker,
33
32
  } from '../tracker/tracker'
34
33
 
@@ -86,56 +85,14 @@ export type SetupEnd = {
86
85
  doctor: DoctorCheck[]
87
86
  }
88
87
 
89
- /** The labels a run needs, with what they're for. */
90
- const LABELS = [
91
- {
92
- name: READY_LABEL,
93
- color: '0e8a16',
94
- description: 'Ready for a Luca run',
95
- },
96
- {
97
- name: REFACTOR_LABEL,
98
- color: '5319e7',
99
- description: 'Changes shape, not behavior: no new tests',
100
- },
101
- {
102
- name: NEEDS_INFO_LABEL,
103
- color: 'd93f0b',
104
- description: 'Luca needs more detail before it can build this',
105
- },
106
- ]
107
-
108
- /** The version-bump labels a repo with changesets gets, one per bump. */
109
- const RELEASE_LABEL_INFO: Record<
110
- (typeof RELEASE_LABELS)[number],
111
- { color: string; description: string }
112
- > = {
113
- 'release:patch': {
114
- color: 'c2e0c6',
115
- description: "A patch bump for this spec's changeset (the default)",
116
- },
117
- 'release:minor': {
118
- color: 'fbca04',
119
- description: "A minor bump for this spec's changeset",
120
- },
121
- 'release:major': {
122
- color: 'b60205',
123
- description: "A major bump for this spec's changeset",
124
- },
125
- 'release:none': {
126
- color: 'ededed',
127
- description: 'No version bump: an empty changeset',
128
- },
129
- }
130
-
131
88
  /** Where a repo that uses changesets keeps their config. */
132
89
  const CHANGESET_CONFIG_FILE = join('.changeset', 'config.json')
133
90
 
134
91
  /** The labels `repo` needs: the run's, plus the release ones with changesets. */
135
92
  const labelsFor = async ({ repo }: { repo: string }) => [
136
- ...LABELS,
93
+ ...RUN_LABELS,
137
94
  ...((await Bun.file(join(repo, CHANGESET_CONFIG_FILE)).exists())
138
- ? RELEASE_LABELS.map((name) => ({ name, ...RELEASE_LABEL_INFO[name] }))
95
+ ? RELEASE_LABELS.map((name) => RELEASE_LABEL_DEFINITIONS[name])
139
96
  : []),
140
97
  ]
141
98
 
@@ -601,7 +558,7 @@ const list = (lines: string[]): string =>
601
558
 
602
559
  /**
603
560
  * Gets `repo` ready for Luca. It creates the missing `ready-for-agent`,
604
- * `refactor`, and `needs-info` labels, and in a repo with
561
+ * `ready-for-human`, `refactor`, and `needs-info` labels, and in a repo with
605
562
  * `.changeset/config.json` the `release:*` ones too; writes a starting
606
563
  * `.luca/config.json` from `package.json` when there is none (see
607
564
  * `guessChecks`), with the repo's GitHub name as its `muninn.vault`, rewrites an old-Luca config into the new shape keeping
@@ -3,7 +3,7 @@ import type {
3
3
  DoneCommit,
4
4
  TestWriterResult,
5
5
  } from '../agents/role-results'
6
- import type { TicketSnapshot } from '../intake/intake-schemas'
6
+ import type { LeftOutTicket, TicketSnapshot } from '../intake/intake-schemas'
7
7
 
8
8
  /**
9
9
  * A ticket whose work is already on the base branch (#484): its
@@ -30,11 +30,13 @@ export type AlreadyDoneAction =
30
30
  /**
31
31
  * End the run with nothing to do: at intake (`already_done` is empty),
32
32
  * or once every ticket's work was already done and those are closed.
33
+ * At intake, `left_out` names the open tickets left for a person (#499).
33
34
  */
34
35
  | {
35
36
  type: 'finish_nothing_to_do'
36
37
  closed_tickets: number[]
37
38
  already_done: number[]
39
+ left_out?: LeftOutTicket[]
38
40
  }
39
41
 
40
42
  /**
@@ -196,6 +196,7 @@ const decideIntake = ({ state }: { state: RunState }): EngineAction => {
196
196
  type: 'finish_nothing_to_do',
197
197
  closed_tickets: result.closed_tickets,
198
198
  already_done: [],
199
+ left_out: result.left_out,
199
200
  }
200
201
  }
201
202
  // A snapshot cut short by a crash is taken again in full; replay
@@ -15,7 +15,11 @@ import {
15
15
  postCommentOnce,
16
16
  type CommentStep,
17
17
  } from '../tracker/post-comment-once'
18
- import type { Tracker, TrackerIssue } from '../tracker/tracker'
18
+ import {
19
+ HUMAN_LABEL,
20
+ type Tracker,
21
+ type TrackerIssue,
22
+ } from '../tracker/tracker'
19
23
 
20
24
  /**
21
25
  * How long the engine waits between reads of the spec issue while stuck
@@ -303,6 +307,16 @@ export const retryTicket = async ({
303
307
  (ticket) => ticket.number === number
304
308
  )
305
309
  : undefined
310
+ const leftOut = (
311
+ checked.outcome === 'passed'
312
+ ? checked.snapshot.left_out
313
+ : checked.left_out
314
+ ).some((ticket) => ticket.number === number)
315
+ if (fresh === undefined && leftOut) {
316
+ return refuse([
317
+ `The ticket is now for a person (\`${HUMAN_LABEL}\`), or waits on one, so a run can't build it.`,
318
+ ])
319
+ }
306
320
  if (fresh === undefined) return refuse(['The ticket is no longer open.'])
307
321
  const sha = await resetToRunBranch()
308
322
  journal.append({
@@ -32,7 +32,7 @@ import type { IntakeProblem } from '../intake/intake-schemas'
32
32
  import { jevAsksAfter, jevAsksBefore } from '../jev/jev-jobs'
33
33
  import { askJevInShadow, type JevShadow } from '../jev/jev-shadow'
34
34
  import type { Journal } from '../journal/journal'
35
- import type { JournalRecord } from '../journal/journal-record'
35
+ import type { JournalRecord, LabelFailure } from '../journal/journal-record'
36
36
  import { replayRun } from '../journal/replay'
37
37
  import {
38
38
  crashesAfter,
@@ -178,6 +178,46 @@ const readIntake = async ({
178
178
  })
179
179
  }
180
180
 
181
+ /**
182
+ * Moves a refused ticket from the ready label to needs-info. A label the
183
+ * tracker can't move doesn't stop the refusal (#500): each failure comes
184
+ * back, to be journaled.
185
+ */
186
+ const moveToNeedsInfo = async ({
187
+ tracker,
188
+ ticket,
189
+ }: {
190
+ tracker: Tracker
191
+ ticket: number
192
+ }): Promise<LabelFailure[]> => {
193
+ const changes = [
194
+ { label: NEEDS_INFO_LABEL, change: 'add' },
195
+ { label: READY_LABEL, change: 'remove' },
196
+ ] as const
197
+ const failures: LabelFailure[] = []
198
+ for (const { label, change } of changes) {
199
+ try {
200
+ await (change === 'add' ? tracker.addLabel : tracker.removeLabel)({
201
+ number: ticket,
202
+ label,
203
+ })
204
+ } catch (error) {
205
+ failures.push({
206
+ ticket,
207
+ label,
208
+ change,
209
+ error: error instanceof Error ? error.message : String(error),
210
+ })
211
+ }
212
+ }
213
+ return failures
214
+ }
215
+
216
+ /**
217
+ * Refuses the run: each bad issue gets its comment (once, even on a redo
218
+ * after a crash) and moves to needs-info. The refusal is journaled even
219
+ * when a label couldn't move, so the run ends refused, not crashed.
220
+ */
181
221
  const refuseIntake = async ({
182
222
  spec_number,
183
223
  problems,
@@ -191,6 +231,7 @@ const refuseIntake = async ({
191
231
  tracker: Tracker
192
232
  step: CommentStep
193
233
  }) => {
234
+ const labelFailures: LabelFailure[] = []
194
235
  for (const [n, { ticket, missing }] of problems.entries()) {
195
236
  if (ticket === null) continue
196
237
  await postCommentOnce({
@@ -200,14 +241,13 @@ const refuseIntake = async ({
200
241
  step,
201
242
  n,
202
243
  })
203
- await tracker.addLabel({ number: ticket, label: NEEDS_INFO_LABEL })
204
- await tracker.removeLabel({ number: ticket, label: READY_LABEL })
244
+ labelFailures.push(...(await moveToNeedsInfo({ tracker, ticket })))
205
245
  }
206
246
  journal.append({
207
247
  kind: 'intake_refused',
208
248
  ticket: null,
209
249
  role: null,
210
- content: { problems },
250
+ content: { problems, label_failures: labelFailures },
211
251
  })
212
252
  }
213
253
 
@@ -506,11 +546,12 @@ export const executeAction = async ({
506
546
  content: {
507
547
  closed_tickets: action.closed_tickets,
508
548
  already_done: action.already_done,
549
+ left_out: action.left_out ?? [],
509
550
  },
510
551
  })
511
552
  return
512
553
  case 'snapshot_intake': {
513
- const { spec, tickets, closed_tickets } = action.snapshot
554
+ const { spec, tickets, closed_tickets, left_out } = action.snapshot
514
555
  journal.append({
515
556
  kind: 'spec_snapshot',
516
557
  ticket: spec.number,
@@ -519,6 +560,7 @@ export const executeAction = async ({
519
560
  spec,
520
561
  ticket_order: tickets.map((ticket) => ticket.number),
521
562
  closed_tickets,
563
+ left_out,
522
564
  },
523
565
  })
524
566
  for (const ticket of tickets) {