@alecsibilia/luca 14.0.0-alpha.13 → 14.0.0-alpha.14

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.14
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.13
4
8
 
5
9
  No changes in this release.
@@ -73,7 +73,11 @@ export const stageColor = ({
73
73
  switch (stage) {
74
74
  case 'building':
75
75
  case 'reviewing':
76
+ case 'testing':
77
+ case 'joining':
76
78
  return theme.colors.accent
79
+ case 'fixing':
80
+ return theme.colors.statusWarning
77
81
  case 'stuck':
78
82
  return theme.colors.statusDanger
79
83
  case 'done':
@@ -55,8 +55,9 @@ import { postedText, type PostedReply } from '../shared/reply-actions'
55
55
  /**
56
56
  * The side panel: one run of this workspace, top to bottom. Plan usage, a
57
57
  * limit-wait banner, "Needs you" pinned on top, the tickets as a stack of
58
- * stages (Blocked → Building → Reviewing → Done → Skipped) with step dots,
59
- * then the final review's 5 lenses as a second stack. It polls `board.read`.
58
+ * stages (Blocked → Building → Reviewing → Testing → Fixing → Joining → Done
59
+ * → Skipped) with step dots, then the final review's 5 lenses as a second
60
+ * stack. It polls `board.read`.
60
61
  */
61
62
 
62
63
  type StackStage = Exclude<TicketStage, 'stuck'>
@@ -78,17 +79,38 @@ const TICKET_STAGES: {
78
79
  {
79
80
  stage: 'building',
80
81
  title: 'Building',
81
- hint: 'tests, red check, code, checks',
82
+ hint: 'the first tests and code',
82
83
  icon: 'Hammer',
83
84
  folded: false,
84
85
  },
85
86
  {
86
87
  stage: 'reviewing',
87
88
  title: 'Reviewing',
88
- hint: 'a fresh ticket review and its fix rounds',
89
+ hint: 'a reviewer reads the ticket',
89
90
  icon: 'ScanSearch',
90
91
  folded: false,
91
92
  },
93
+ {
94
+ stage: 'testing',
95
+ title: 'Testing',
96
+ hint: 'the red check or the checks running',
97
+ icon: 'FlaskConical',
98
+ folded: false,
99
+ },
100
+ {
101
+ stage: 'fixing',
102
+ title: 'Fixing',
103
+ hint: 'a fix round after the checks, a review or a join',
104
+ icon: 'Wrench',
105
+ folded: false,
106
+ },
107
+ {
108
+ stage: 'joining',
109
+ title: 'Joining',
110
+ hint: 'joining the run branch and its checks there',
111
+ icon: 'GitMerge',
112
+ folded: false,
113
+ },
92
114
  {
93
115
  stage: 'done',
94
116
  title: 'Done',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.13",
3
+ "version": "14.0.0-alpha.14",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -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.13"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.14"
@@ -21,6 +21,7 @@ import {
21
21
  type RunInfo,
22
22
  type RunStatus,
23
23
  type TicketCard,
24
+ type TicketStage,
24
25
  type Usage,
25
26
  } from '../shared/board-state'
26
27
  import {
@@ -494,9 +495,40 @@ const withoutSteps = ({ state }: { state: BoardState }): BoardState =>
494
495
  run_steps: [],
495
496
  })
496
497
 
498
+ /** The stages a step doesn't move a ticket out of. */
499
+ const SETTLED_STAGES = new Set<TicketStage>(['stuck', 'done', 'skipped'])
500
+
497
501
  /**
498
- * A step began: it is its ticket's current step, or one of the run's.
499
- * A new step on the same key (a crash's redo) replaces the one before it.
502
+ * The stage a ticket's step puts it in (#549): the red check and the checks
503
+ * are testing, but the checks after joining are still joining; joining and
504
+ * pushing the run branch are joining. Any other step, or a stuck, done or
505
+ * skipped ticket, keeps its stage.
506
+ */
507
+ const stepStage = ({
508
+ card,
509
+ step,
510
+ }: {
511
+ card: TicketCard
512
+ step: string
513
+ }): TicketStage => {
514
+ if (SETTLED_STAGES.has(card.stage)) return card.stage
515
+ switch (step) {
516
+ case 'run_red_check':
517
+ return 'testing'
518
+ case 'run_gates':
519
+ return card.stage === 'joining' ? 'joining' : 'testing'
520
+ case 'join_run_branch':
521
+ case 'push_run_branch':
522
+ return 'joining'
523
+ default:
524
+ return card.stage
525
+ }
526
+ }
527
+
528
+ /**
529
+ * A step began: it is its ticket's current step, or one of the run's, and
530
+ * it sets the ticket's stage. A new step on the same key (a crash's redo)
531
+ * replaces the one before it.
500
532
  */
501
533
  const stepStarted = ({
502
534
  state,
@@ -506,7 +538,7 @@ const stepStarted = ({
506
538
  record: Extract<BoardRecord, { kind: 'step_started' }>
507
539
  }): BoardState => {
508
540
  // A replayed journal of an engine that ended has no step running.
509
- if (state.run.engine_ended !== null) return state
541
+ const ended = state.run.engine_ended !== null
510
542
  const current_step = {
511
543
  text: stepText({ step: record.content.step }),
512
544
  since: record.time,
@@ -515,9 +547,14 @@ const stepStarted = ({
515
547
  return updateTicket({
516
548
  state,
517
549
  number: record.ticket,
518
- update: (card) => ({ ...card, current_step }),
550
+ update: (card) => ({
551
+ ...card,
552
+ stage: stepStage({ card, step: record.content.step }),
553
+ current_step: ended ? card.current_step : current_step,
554
+ }),
519
555
  })
520
556
  }
557
+ if (ended) return state
521
558
  const { key } = record.content
522
559
  return withRunSteps({
523
560
  state,
@@ -1096,6 +1133,7 @@ const applyKind = ({
1096
1133
  number: ticket,
1097
1134
  update: (card) => ({
1098
1135
  ...card,
1136
+ stage: 'building',
1099
1137
  step: 0,
1100
1138
  fix_round: 0,
1101
1139
  open_check: null,
@@ -1177,11 +1215,13 @@ const applyKind = ({
1177
1215
  return updateTicket({
1178
1216
  state,
1179
1217
  number: ticket,
1218
+ // Joined, it is done once its checks there pass and the run
1219
+ // branch is pushed.
1180
1220
  update: (card) =>
1181
1221
  record.content.ok
1182
1222
  ? {
1183
1223
  ...card,
1184
- stage: 'done',
1224
+ stage: 'joining',
1185
1225
  step: ALL_STEPS_DONE,
1186
1226
  role: null,
1187
1227
  activity: 'joined',
@@ -1203,6 +1243,7 @@ const applyKind = ({
1203
1243
  number: ticket,
1204
1244
  update: (card) => ({
1205
1245
  ...card,
1246
+ stage: 'fixing',
1206
1247
  step: 0,
1207
1248
  role: null,
1208
1249
  activity: 'updating tests for the run branch',
@@ -1217,8 +1258,8 @@ const applyKind = ({
1217
1258
  state,
1218
1259
  number: ticket,
1219
1260
  update: (card) =>
1220
- card.stage === 'done'
1221
- ? { ...card, activity: 'pushed' }
1261
+ card.stage === 'joining' || card.stage === 'done'
1262
+ ? { ...card, stage: 'done', activity: 'pushed' }
1222
1263
  : card,
1223
1264
  })
1224
1265
  case 'ticket_stuck': {
@@ -1553,7 +1594,7 @@ const OPEN_CHECK_TEXT = {
1553
1594
  /**
1554
1595
  * A fixer's start in a review fix round. The round is the review's round (a
1555
1596
  * round follows each review that asked for changes); only the round's first
1556
- * fixer adds its "tried" line. The card stays in Reviewing at its step.
1597
+ * fixer adds its "tried" line. The card is fixing, at its step.
1557
1598
  */
1558
1599
  const reviewFixStarted = ({
1559
1600
  card,
@@ -1570,7 +1611,7 @@ const reviewFixStarted = ({
1570
1611
  ...card,
1571
1612
  started: true,
1572
1613
  role,
1573
- stage: 'reviewing',
1614
+ stage: 'fixing',
1574
1615
  review_fix_round: round,
1575
1616
  activity: `fixing the review's findings (${round}/${LOOP_CAP})`,
1576
1617
  tried:
@@ -1613,15 +1654,19 @@ const agentStarted = ({
1613
1654
  })
1614
1655
  : card.tried
1615
1656
  const fixing = kind === 'fix' || kind === 'resent'
1657
+ // A writer answering an open check, or fixing on top of the run
1658
+ // branch, is fixing; its first pass is building.
1659
+ const stage =
1660
+ card.open_check !== null || card.stage === 'fixing'
1661
+ ? 'fixing'
1662
+ : 'building'
1616
1663
  if (role === 'test-writer') {
1617
- // During a ticket review (a retried review fixer) the card
1618
- // stays in Reviewing at its step.
1619
- const reviewing = card.stage === 'reviewing'
1664
+ // A retried review fixer stays at its step.
1620
1665
  return {
1621
1666
  ...started,
1622
1667
  tried,
1623
- stage: reviewing ? 'reviewing' : 'building',
1624
- step: reviewing ? card.step : 0,
1668
+ stage,
1669
+ step: card.open_check === 'review' ? card.step : 0,
1625
1670
  activity: fixing
1626
1671
  ? `fixing tests (${fix_round}/${LOOP_CAP})`
1627
1672
  : kind === 'retry'
@@ -1633,8 +1678,7 @@ const agentStarted = ({
1633
1678
  return {
1634
1679
  ...started,
1635
1680
  tried,
1636
- stage:
1637
- card.stage === 'reviewing' ? 'reviewing' : 'building',
1681
+ stage,
1638
1682
  step: 2,
1639
1683
  activity: fixing
1640
1684
  ? `fixing (${fix_round}/${LOOP_CAP})`
@@ -1724,9 +1768,12 @@ const agentFinished = ({
1724
1768
  }),
1725
1769
  done.tried
1726
1770
  )
1771
+ // Approved, it joins the run branch next; else a review fix
1772
+ // round follows.
1727
1773
  return result.verdict === 'approve'
1728
1774
  ? {
1729
1775
  ...done,
1776
+ stage: 'joining',
1730
1777
  step: ALL_STEPS_DONE,
1731
1778
  findings,
1732
1779
  open_check: null,
@@ -1735,6 +1782,7 @@ const agentFinished = ({
1735
1782
  }
1736
1783
  : {
1737
1784
  ...done,
1785
+ stage: 'fixing',
1738
1786
  findings,
1739
1787
  open_check: 'review',
1740
1788
  tried: ruled,
@@ -1828,9 +1876,9 @@ export const testsSentBackText = ({
1828
1876
  }
1829
1877
 
1830
1878
  /**
1831
- * A ticket sent back onto the run branch builds again: its tests first if
1832
- * test files clashed, else its code; then the checks and a fresh review,
1833
- * whose fix rounds count from zero again.
1879
+ * A ticket sent back onto the run branch is fixed on top of it: its tests
1880
+ * first if test files clashed, else its code; then the checks and a fresh
1881
+ * review, whose fix rounds count from zero again.
1834
1882
  */
1835
1883
  const ticketRebased = ({
1836
1884
  state,
@@ -1844,7 +1892,7 @@ const ticketRebased = ({
1844
1892
  number: record.ticket,
1845
1893
  update: (card) => ({
1846
1894
  ...card,
1847
- stage: 'building',
1895
+ stage: 'fixing',
1848
1896
  step: record.content.tests.length > 0 ? 0 : 2,
1849
1897
  role: null,
1850
1898
  fix_round: 0,
@@ -2054,11 +2102,18 @@ export const unfinishedBlockers = ({
2054
2102
  return blocker !== undefined && blocker.stage !== 'done'
2055
2103
  })
2056
2104
 
2105
+ /** The stages of a ticket the engine is working on. */
2106
+ const ACTIVE_STAGES = new Set<TicketStage>([
2107
+ 'building',
2108
+ 'reviewing',
2109
+ 'testing',
2110
+ 'fixing',
2111
+ 'joining',
2112
+ ])
2113
+
2057
2114
  const isActive = ({ state }: { state: BoardState }): boolean =>
2058
2115
  state.tickets.some(
2059
- (ticket) =>
2060
- ticket.started &&
2061
- (ticket.stage === 'building' || ticket.stage === 'reviewing')
2116
+ (ticket) => ticket.started && ACTIVE_STAGES.has(ticket.stage)
2062
2117
  ) ||
2063
2118
  state.final_review.state === 'reviewing' ||
2064
2119
  state.final_review.state === 'fixing'
@@ -177,10 +177,20 @@ export const RunPhaseSchema = z.enum([
177
177
 
178
178
  export type RunPhase = z.infer<typeof RunPhaseSchema>
179
179
 
180
+ /**
181
+ * What a ticket is really doing (#549): `building` its first test-writer and
182
+ * implementer pass, `reviewing` only while a reviewer works, `testing` the red
183
+ * check or the checks, `fixing` any fix round, `joining` the run branch and
184
+ * the checks there. Every older stage is still one, so a stored board state
185
+ * from before still parses.
186
+ */
180
187
  export const TicketStageSchema = z.enum([
181
188
  'blocked',
182
189
  'building',
183
190
  'reviewing',
191
+ 'testing',
192
+ 'fixing',
193
+ 'joining',
184
194
  'stuck',
185
195
  'done',
186
196
  'skipped',
@@ -203,8 +203,9 @@ export const CHANGESETS_SECTION = [
203
203
  ].join('\n\n')
204
204
 
205
205
  /**
206
- * The section the rules lens gets in a repo with changesets, so a rule like
207
- * "every PR needs a changeset" raises no finding.
206
+ * The section every reviewer (the ticket-reviewer and each lens) gets in a
207
+ * repo with changesets, so a rule like "every PR needs a changeset" raises
208
+ * no finding.
208
209
  */
209
210
  export const CHANGESETS_LENS_SECTION = [
210
211
  '## The engine writes the changeset',
@@ -218,8 +218,11 @@ const driveRun = async ({
218
218
  }
219
219
 
220
220
  /** A value of a reloaded config field in a log line; `null` is "none". */
221
- const fieldValue = (value: string | number | null): string =>
222
- value === null ? 'none' : String(value)
221
+ const fieldValue = (value: string | number | string[] | null): string => {
222
+ if (value === null) return 'none'
223
+ if (Array.isArray(value)) return `[${value.join(', ')}]`
224
+ return String(value)
225
+ }
223
226
 
224
227
  /**
225
228
  * The log line for a resume's `config_reloaded` or `config_reload_refused`
@@ -1,3 +1,4 @@
1
+ import isEqual from 'lodash/isEqual'
1
2
  import omit from 'lodash/omit'
2
3
  import reduce from 'lodash/reduce'
3
4
  import { z } from 'zod'
@@ -7,10 +8,10 @@ import type { EngineConfig } from './engine-config'
7
8
  /**
8
9
  * The config fields a resumed run takes from the repo's
9
10
  * `.luca/config.json` again (#516). They say how the engine builds a
10
- * checkout before its tests, or how long a test may run, not what the
11
- * tests are, so a run can change them halfway without making its earlier
12
- * results wrong: a repo that swaps a slow `prepare` for a fast cached one
13
- * gets it on the next resume.
11
+ * checkout before its tests, how long a test may run, or which new docs
12
+ * may be committed, not what the tests are, so a run can change them
13
+ * halfway without making its earlier results wrong: a repo that swaps a
14
+ * slow `prepare` for a fast cached one gets it on the next resume.
14
15
  *
15
16
  * - `prepare`: the command run before each test run in a checkout.
16
17
  * - `prepare_timeout_ms`: how long that command may run.
@@ -19,12 +20,16 @@ import type { EngineConfig } from './engine-config'
19
20
  * one counts from the next prepare run.
20
21
  * - `test_timeout_ms`: how long each test command may run (#542). Each run
21
22
  * of a test command reads it then, so a new one counts from the next.
23
+ * - `leftover_allow`: more new markdown the leftover scan lets through
24
+ * (#520). Each scan reads it then, so a run stuck on a leftover can be
25
+ * unstuck by allowing the file and resuming.
22
26
  */
23
27
  export const RELOADED_FIELDS = [
24
28
  'prepare',
25
29
  'prepare_timeout_ms',
26
30
  'prepare_concurrency',
27
31
  'test_timeout_ms',
32
+ 'leftover_allow',
28
33
  ] as const
29
34
 
30
35
  /**
@@ -61,6 +66,7 @@ export const FROZEN_FIELDS = [
61
66
  ] as const
62
67
 
63
68
  const positiveInt = z.number().int().positive().nullable()
69
+ const patterns = z.array(z.string().min(1)).nullable()
64
70
 
65
71
  /**
66
72
  * One reloaded field that changed: its value in the run before (`from`)
@@ -88,6 +94,11 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
88
94
  from: positiveInt,
89
95
  to: positiveInt,
90
96
  }),
97
+ z.object({
98
+ field: z.literal('leftover_allow'),
99
+ from: patterns,
100
+ to: patterns,
101
+ }),
91
102
  ])
92
103
 
93
104
  export type ConfigChange = z.infer<typeof ConfigChangeSchema>
@@ -129,8 +140,13 @@ export const configChanges = ({
129
140
  from: current.test_timeout_ms ?? null,
130
141
  to: next.test_timeout_ms ?? null,
131
142
  },
143
+ {
144
+ field: 'leftover_allow',
145
+ from: current.leftover_allow ?? null,
146
+ to: next.leftover_allow ?? null,
147
+ },
132
148
  ]
133
- return all.filter(({ from, to }) => from !== to)
149
+ return all.filter(({ from, to }) => !isEqual(from, to))
134
150
  }
135
151
 
136
152
  const applyChange = ({
@@ -157,6 +173,10 @@ const applyChange = ({
157
173
  return change.to === null
158
174
  ? omit(config, 'test_timeout_ms')
159
175
  : { ...config, test_timeout_ms: change.to }
176
+ case 'leftover_allow':
177
+ return change.to === null
178
+ ? omit(config, 'leftover_allow')
179
+ : { ...config, leftover_allow: change.to }
160
180
  }
161
181
  }
162
182
 
@@ -92,6 +92,12 @@ export const EngineConfigSchema = z.object({
92
92
  * engine's default (`DEFAULT_COMMAND_TIMEOUT_MS`, 5 minutes).
93
93
  */
94
94
  test_timeout_ms: z.number().int().positive().optional(),
95
+ /**
96
+ * Glob patterns for more new markdown the leftover scan lets through,
97
+ * beyond doc folders, READMEs, and files the repo names (#520), such as
98
+ * `design/*.md`; left out, none.
99
+ */
100
+ leftover_allow: z.array(z.string().min(1)).optional(),
95
101
  test_file_patterns: z.array(z.string()).default(['**/*.test.ts']),
96
102
  test_setup_files: z.array(z.string()).default([]),
97
103
  rule_files: z.array(z.string()).default([]),
@@ -1341,11 +1341,13 @@ const withWriterSection = ({
1341
1341
  }
1342
1342
 
1343
1343
  /**
1344
- * In a repo with changesets, the writers are told not to write one and the
1345
- * rules lens that the engine adds the run's one at PR time (#507).
1344
+ * In a repo with changesets, the writers are told not to write one (#507),
1345
+ * and every reviewer (the ticket-reviewer and each lens) that the engine adds
1346
+ * the run's one at PR time (#507, #545).
1346
1347
  */
1347
1348
  const withChangesetsSections = (step: BuildAction): BuildAction =>
1348
- step.type === 'launch_lens' && step.lens === 'rules'
1349
+ step.type === 'launch_lens' ||
1350
+ (step.type === 'launch_agent' && step.role === 'ticket-reviewer')
1349
1351
  ? { ...step, prompt: `${step.prompt}\n\n${CHANGESETS_LENS_SECTION}` }
1350
1352
  : withWriterSection({ step, section: CHANGESETS_SECTION })
1351
1353
 
@@ -30,9 +30,10 @@ import { endedText } from '../gates/gate-schemas'
30
30
  import {
31
31
  importStem,
32
32
  isAgentChangeset,
33
+ leftoverPasses,
34
+ nameCheckedFiles,
33
35
  newCodeFiles,
34
36
  scanLeftovers,
35
- scratchNamedFiles,
36
37
  } from '../gates/leftover-scan'
37
38
  import {
38
39
  dependenciesChanged,
@@ -462,7 +463,8 @@ const isUsed = async ({
462
463
 
463
464
  /**
464
465
  * Whether a file other than itself and the tests names this scratch-named
465
- * file by its basename, as a TOC, `package.json`, or a config does: a
466
+ * file or new markdown (#520) by its basename, as a TOC, `package.json`, a
467
+ * doc index, or a config does: a
466
468
  * tracked file, or one this change adds (a new addon's TOC names its new
467
469
  * modules). A mention in a test doesn't make a file the repo's.
468
470
  */
@@ -604,23 +606,26 @@ export const commitIn = async ({
604
606
  used_code[path] = await isUsed({ context, cwd, path, added_texts })
605
607
  }
606
608
  const named: string[] = []
607
- for (const path of scratchNamedFiles({ changes })) {
609
+ for (const path of nameCheckedFiles({ changes })) {
608
610
  if (await isNamed({ context, cwd, path, added_texts })) {
609
611
  named.push(path)
610
612
  }
611
613
  }
612
- const hits = scanLeftovers({
614
+ const inputs = {
613
615
  changes,
614
616
  test_files,
615
617
  mention_text,
616
618
  used_code,
617
619
  named,
618
- })
620
+ leftover_allow: context.config.leftover_allow ?? [],
621
+ }
622
+ const hits = scanLeftovers(inputs)
623
+ const passed = leftoverPasses(inputs)
619
624
  context.journal.append({
620
625
  kind: 'leftover_scan',
621
626
  ticket,
622
627
  role: null,
623
- content: { stage, hits },
628
+ content: { stage, hits, ...(passed.length > 0 ? { passed } : {}) },
624
629
  })
625
630
  if (hits.length > 0) return
626
631
  const adopted =
@@ -41,6 +41,13 @@ const REASON_LINES: Record<StuckReason, string> = {
41
41
  'Undoing its join would have rewound the run branch over other work, so the run branch was left alone.',
42
42
  }
43
43
 
44
+ /**
45
+ * How to keep a flagged doc the repo wants, for a `leftovers_found` stuck
46
+ * (#520); `retry` is the reply that retries it.
47
+ */
48
+ const leftoverAllowHint = ({ retry }: { retry: string }): string =>
49
+ `If one is a doc the repo wants, add a pattern for it to \`leftover_allow\` in the repo's \`.luca/config.json\` instead, resume the run (only a resume reads the config again, a plain \`retry\` doesn't), then reply ${retry}.`
50
+
44
51
  /** What the user could do, per reason; `n` is the ticket. */
45
52
  const suggestion = ({
46
53
  reason,
@@ -58,7 +65,7 @@ const suggestion = ({
58
65
  case 'nothing_new_to_test':
59
66
  return `A ticket that changes no behavior is a refactor ticket: add the \`${REFACTOR_LABEL}\` label, then reply ${retry}. It is built again as a refactor, with no new tests.`
60
67
  case 'leftovers_found':
61
- return `Delete those files in the worktree (or move them out of it), then reply ${retry}.`
68
+ return `Delete those files in the worktree (or move them out of it), then reply ${retry}. ${leftoverAllowHint({ retry })}`
62
69
  case 'gates_failed':
63
70
  return `Fix the failing check in the worktree, or make the ticket clearer, then reply ${retry}.`
64
71
  case 'bad_test':
@@ -198,7 +205,7 @@ const finalSuggestion = ({ reason }: { reason: StuckReason }): string => {
198
205
  case 'changes_requested':
199
206
  return "Fix the open findings in the run branch's worktree, then reply `retry`; or reply `ship` if they can wait for review in the PR."
200
207
  case 'leftovers_found':
201
- return "Delete those files in the run branch's worktree, then reply `retry`."
208
+ return `Delete those files in the run branch's worktree, then reply \`retry\`. ${leftoverAllowHint({ retry: '`retry`' })}`
202
209
  default:
203
210
  return "Read the last error, fix what trips it in the run branch's worktree, then reply `retry`; or reply `ship` to open the PR anyway."
204
211
  }
@@ -82,3 +82,32 @@ export const LeftoverHitSchema = z.object({
82
82
  })
83
83
 
84
84
  export type LeftoverHit = z.infer<typeof LeftoverHitSchema>
85
+
86
+ /**
87
+ * Why the leftover scan let a new markdown file through (#520): it sits in a
88
+ * doc place (a `docs` or `adr` folder, or a `README.md`), a tracked or newly
89
+ * added non-test file names it, or a `leftover_allow` pattern matches it.
90
+ */
91
+ export const LeftoverPassReasonSchema = z.enum([
92
+ 'doc_place',
93
+ 'named_by_repo',
94
+ 'config_pattern',
95
+ ])
96
+
97
+ export type LeftoverPassReason = z.infer<typeof LeftoverPassReasonSchema>
98
+
99
+ /**
100
+ * A new markdown file the leftover scan let through, and why, as the journal
101
+ * keeps it. The reason is a string there, so a later reason still reads.
102
+ */
103
+ export const LeftoverPassSchema = z.object({
104
+ path: z.string(),
105
+ reason: z.string(),
106
+ })
107
+
108
+ /** A new markdown file the leftover scan let through, as the scan returns it. */
109
+ export const LeftoverPassResultSchema = LeftoverPassSchema.extend({
110
+ reason: LeftoverPassReasonSchema,
111
+ })
112
+
113
+ export type LeftoverPass = z.infer<typeof LeftoverPassResultSchema>