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

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.15
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.14
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.14",
3
+ "version": "14.0.0-alpha.15",
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.14"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.15"
@@ -394,6 +394,21 @@ export const describeRecord = ({
394
394
  tone: 'info',
395
395
  })
396
396
  case 'gates_run': {
397
+ const failed = record.content.checks
398
+ .filter((check) => !check.ok)
399
+ .map((check) => check.name)
400
+ // The final review's slow tests, after round 1's lenses (#563).
401
+ if (record.content.target === 'final_slow') {
402
+ return record.content.ok
403
+ ? event({
404
+ text: 'Final review: slow tests passed.',
405
+ tone: 'success',
406
+ })
407
+ : event({
408
+ text: `Final review: slow tests failed: ${failed.join(', ') || 'no detail'}.`,
409
+ tone: 'danger',
410
+ })
411
+ }
397
412
  const where =
398
413
  record.ticket === null
399
414
  ? ' on the fixes'
@@ -401,9 +416,6 @@ export const describeRecord = ({
401
416
  ? ' after joining'
402
417
  : ''
403
418
  const who = record.ticket === null ? 'Final review: ' : at
404
- const failed = record.content.checks
405
- .filter((check) => !check.ok)
406
- .map((check) => check.name)
407
419
  return record.content.ok
408
420
  ? event({
409
421
  text: `${who}checks passed${where}.`,
@@ -53,7 +53,8 @@ const STUCK_REASONS: Record<string, string> = {
53
53
  leftovers_found:
54
54
  'The leftover scan found files that must not be committed.',
55
55
  gates_failed: 'The checks failed.',
56
- bad_test: 'The implementer sent back a bad test.',
56
+ bad_test:
57
+ 'A test was sent back as bad, by the implementer or because it checks nothing.',
57
58
  changes_requested: 'The ticket review asked for changes.',
58
59
  join_failed: "The ticket couldn't join the run branch.",
59
60
  join_gates_failed: 'The checks failed after the ticket joined.',
@@ -437,6 +438,7 @@ const STEP_WORDS: Record<string, string> = {
437
438
  run_red_check: 'running the red check',
438
439
  run_gates: 'running the checks',
439
440
  run_final_gates: 'running the checks',
441
+ run_final_slow_tests: 'running the slow tests',
440
442
  join_run_branch: 'joining the run branch',
441
443
  push_run_branch: 'pushing the run branch',
442
444
  push_final_fixes: 'pushing the run branch',
@@ -1533,7 +1535,16 @@ const applyKind = ({
1533
1535
  fix_round: record.content.round,
1534
1536
  tried: withTried({
1535
1537
  tried: review.tried,
1536
- line: `Fix round ${record.content.round}/${LOOP_CAP} on the lenses' findings`,
1538
+ // Every lens finished clean: only failed slow tests
1539
+ // open a fix round then (#563).
1540
+ line: `Fix round ${record.content.round}/${LOOP_CAP} on ${
1541
+ review.lenses.length > 0 &&
1542
+ review.lenses.every(
1543
+ (lens) => lens.state === 'clean'
1544
+ )
1545
+ ? 'the failed slow tests'
1546
+ : "the lenses' findings"
1547
+ }`,
1537
1548
  }),
1538
1549
  }),
1539
1550
  })
@@ -1807,6 +1818,15 @@ const gatesRun = ({
1807
1818
  .map((check) => check.name)
1808
1819
  .join(', ')
1809
1820
  const afterJoin = target === 'run_branch'
1821
+ // The final review's slow tests, after round 1's lenses (#563).
1822
+ if (target === 'final_slow') {
1823
+ return ok
1824
+ ? state
1825
+ : finalTried({
1826
+ state,
1827
+ line: `The final review's slow tests failed: ${failed || 'no detail'}`,
1828
+ })
1829
+ }
1810
1830
  // The final review's fixes are checked on the run branch, with no ticket.
1811
1831
  if (record.ticket === null) {
1812
1832
  return ok
@@ -1,6 +1,13 @@
1
1
  import { lensOf, type AgentRole, type LensName } from './role-results'
2
2
 
3
- import { testCommands, type EngineConfig } from '../config/engine-config'
3
+ import {
4
+ caseLimitOf,
5
+ featureMapPath,
6
+ slowTestCommands,
7
+ testCommands,
8
+ type EngineConfig,
9
+ } from '../config/engine-config'
10
+ import { MAX_TESTS_PER_CRITERION } from '../core/loop-caps'
4
11
  import {
5
12
  checkCommands,
6
13
  guardRoleOf,
@@ -74,6 +81,20 @@ Sometimes other tickets joined the run branch after this ticket's tests were wri
74
81
  - Every criterion still needs a test. Your updated tests may pass now, since the code is there; that is fine. The engine runs every test in the gates after the implementer's turn.
75
82
  - Answer "tests_written" with the full criterion mapping again.`
76
83
 
84
+ /** The test-writer's lines about the feature map (#583), or nothing without one. */
85
+ const featureMapLines = ({ config }: { config: EngineConfig }): string => {
86
+ const path = featureMapPath({ config })
87
+ return path === null
88
+ ? ''
89
+ : `- Read ${path} first: it lists each feature and the test files that prove it.\n- Put new tests in a feature's existing test files when you can. A new test file must be listed under its feature in ${path}; you may edit that file.\n`
90
+ }
91
+
92
+ /** What the test-writer's "ONLY test files" line adds for the feature map, or nothing. */
93
+ const featureMapClause = ({ config }: { config: EngineConfig }): string => {
94
+ const path = featureMapPath({ config })
95
+ return path === null ? '' : ` and the feature map ${path}`
96
+ }
97
+
77
98
  const TEST_WRITER = ({
78
99
  config,
79
100
  }: {
@@ -82,10 +103,11 @@ const TEST_WRITER = ({
82
103
 
83
104
  You write the failing tests for ONE ticket, before any code exists.
84
105
 
85
- - Create or edit ONLY test files (${config.test_file_patterns.join(', ')}). No other file: no implementation, no package.json, no config, no notes, and not the test setup files.
106
+ - Create or edit ONLY test files (${config.test_file_patterns.join(', ')})${featureMapClause({ config })}. No other file: no implementation, no package.json, no config, no notes, and not the test setup files.
86
107
  - Your new tests must FAIL right now, because the code does not exist yet. That is expected. Do not create or stub the module under test.
87
108
  - Test only the public behavior the ticket names, at the seams the spec's "Testing Decisions" section names. No mocks unless the spec asks. No tautological asserts.
88
- - Every acceptance criterion needs at least one test. Old tests must keep passing.
109
+ ${featureMapLines({ config })}- Every acceptance criterion needs at least one test, and at most ${MAX_TESTS_PER_CRITERION}: write the fewest tests that prove it. Old tests must keep passing.
110
+ - Map every test you add. The red check refuses a new test your criterion mapping doesn't name.
89
111
  - Give every test a plain string-literal name (no test.each, no template names), so the engine can find each one before the module exists.
90
112
 
91
113
  Your result (structured output):
@@ -111,6 +133,25 @@ const testFileRule = ({
111
133
  ? `- You may follow renames and moves into test files (${config.test_file_patterns.join(', ')}), but never change what a test checks. Never touch a test setup file${setupFiles({ config })}.`
112
134
  : `- Never create, edit, or delete a test file (${config.test_file_patterns.join(', ')}) or a test setup file${setupFiles({ config })}.`
113
135
 
136
+ /** The implementer's line about the slow tests (#563), or nothing without them. */
137
+ const slowLine = ({ config }: { config: EngineConfig }): string => {
138
+ const slow = slowTestCommands({ config }).map(({ run }) => `\`${run}\``)
139
+ return slow.length === 0
140
+ ? ''
141
+ : ` The slow tests (${slow.join(', ')}) run at the final review, and the ticket's gates also run the slow test files it added or changed; you may run them yourself.`
142
+ }
143
+
144
+ /**
145
+ * The implementer's line about the fast test limit (#580), or nothing without
146
+ * one.
147
+ */
148
+ const limitLine = ({ config }: { config: EngineConfig }): string => {
149
+ const limit = caseLimitOf({ config })
150
+ return limit === null
151
+ ? ''
152
+ : `- Each fast test must finish within ${limit} ms. If one of the ticket's tests can't, answer "bad_test".\n`
153
+ }
154
+
114
155
  const IMPLEMENTER = ({
115
156
  config,
116
157
  may_edit_tests,
@@ -128,8 +169,8 @@ You write the code that makes ONE ticket's failing tests pass. Another agent wro
128
169
  ]
129
170
  .filter((command) => command !== undefined)
130
171
  .map((command) => `\`${command}\``)
131
- .join(', ')}.
132
- ${testFileRule({ config, may_edit_tests })}
172
+ .join(', ')}.${slowLine({ config })}
173
+ ${limitLine({ config })}${testFileRule({ config, may_edit_tests })}
133
174
  - If a test is wrong (it contradicts the ticket or spec, or no correct code can pass it), don't work around it: answer outcome "bad_test" with the test and your reason.
134
175
  - If the gates can't pass without a change to a test setup file${setupFiles({ config })}, don't work around it: answer outcome "needs_setup_change", with the file and why in "setup_change". The user makes the change.
135
176
  - Follow the spec's Implementation Decisions. Keep the change small: no extra features, and no new dependencies unless the spec says so.
@@ -1,4 +1,4 @@
1
- import type { AgentRole, BadTest, Finding } from './role-results'
1
+ import type { AgentRole, BadTest, BadTestSource, Finding } from './role-results'
2
2
 
3
3
  import type { SpecSnapshot, TicketSnapshot } from '../intake/intake-schemas'
4
4
  import type { RejoinCause } from '../journal/journal-record'
@@ -137,11 +137,21 @@ const REFACTOR_TASK =
137
137
  'Make every gate pass. You may follow renames and moves into test files, but you must not change what a test checks. ' +
138
138
  'If a test is wrong, answer "bad_test" with your reason.'
139
139
 
140
- /** What a fresh test-writer is told when it replaces a bad test. */
141
- const badTestSection = ({ bad_test }: { bad_test: BadTest }): string =>
140
+ /**
141
+ * What a fresh test-writer is told when it replaces a bad test: the
142
+ * implementer sent it back, or the engine found it passes with no
143
+ * `expect()`, so it checks nothing (#581).
144
+ */
145
+ const badTestSection = ({
146
+ bad_test,
147
+ by,
148
+ }: {
149
+ bad_test: BadTest
150
+ by: BadTestSource
151
+ }): string =>
142
152
  [
143
153
  '## A test was sent back as a bad test',
144
- `The implementer says this test is wrong:\n\n- File: ${bad_test.file}\n- Test: ${bad_test.name}\n- Reason: ${bad_test.reason}`,
154
+ `${by === 'engine' ? 'The engine found this test checks nothing' : 'The implementer says this test is wrong'}:\n\n- File: ${bad_test.file}\n- Test: ${bad_test.name}\n- Reason: ${bad_test.reason}`,
145
155
  'Fix or replace it so it checks what its criterion asks. Keep the other tests unless they are wrong too. ' +
146
156
  'Answer with the full criterion mapping again: every criterion, the kept tests included.',
147
157
  ].join('\n\n')
@@ -190,6 +200,26 @@ export const prepareSection = ({ command }: { command: string }): string =>
190
200
  "If the engine's prepare command fails after your turn, its output comes back to you like a failed check.",
191
201
  ].join('\n\n')
192
202
 
203
+ /**
204
+ * The section a test-writer gets when the repo has a feature map (#583): the
205
+ * one file besides test files it may edit. It comes after the task's "Edit
206
+ * test files only", so it reads as that line's exception.
207
+ *
208
+ * @param path - The map's path, as `featureMapPath` gives it.
209
+ *
210
+ * @example
211
+ * featureMapSection({ path: 'docs/feature-map.md' })
212
+ * // '## The feature map\n\nBesides test files, you may edit the feature map, `docs/feature-map.md`. ...'
213
+ */
214
+ export const featureMapSection = ({ path }: { path: string }): string =>
215
+ [
216
+ '## The feature map',
217
+ `Besides test files, you may edit the feature map, \`${path}\`. ` +
218
+ 'Read it first: it lists each feature and the test files that prove it. ' +
219
+ "Put new tests in a feature's existing test files when you can; " +
220
+ 'a new test file must be listed under its feature in the map.',
221
+ ].join('\n\n')
222
+
193
223
  /**
194
224
  * The section a test-writer or implementer (a ticket's or the final
195
225
  * review's fixers) gets in a repo with changesets: the engine writes the
@@ -219,8 +249,8 @@ const LENS_TASK_TEXT =
219
249
 
220
250
  const ROLE_TASKS: Record<AgentRole, string> = {
221
251
  'test-writer':
222
- 'Write failing tests for every acceptance criterion below. Edit test files only. ' +
223
- 'Map each criterion id to the tests that check it.',
252
+ 'Write failing tests for every acceptance criterion below: the fewest that prove each one. Edit test files only. ' +
253
+ 'Map every test you add to the criterion it checks.',
224
254
  implementer:
225
255
  'Make every gate pass. Never edit a test file. ' +
226
256
  'If a test is wrong, answer "bad_test" with your reason. ' +
@@ -259,7 +289,10 @@ export const roleTask = ({ role }: { role: AgentRole }): string =>
259
289
  * @param refactor - The ticket is a refactor ticket: the implementer gets
260
290
  * the refactor task (no new behavior; it may follow renames into tests).
261
291
  * @param bad_test - For a fresh test-writer after a bad-test bounce: the
262
- * test the implementer sent back, and why.
292
+ * test the implementer sent back, or the engine found checks nothing
293
+ * (#581), and why.
294
+ * @param bad_test_by - Who sent `bad_test` back: the implementer (the
295
+ * default) or the engine.
263
296
  * @param sections - More sections for the end, such as a reviewer's diff
264
297
  * and gate results, or a review fixer's findings.
265
298
  * @param run_notes - Notes earlier agents in the run left, oldest first,
@@ -274,6 +307,7 @@ export const rolePrompt = ({
274
307
  ticket,
275
308
  refactor,
276
309
  bad_test,
310
+ bad_test_by,
277
311
  sections,
278
312
  run_notes,
279
313
  }: {
@@ -282,6 +316,7 @@ export const rolePrompt = ({
282
316
  ticket: TicketSnapshot
283
317
  refactor?: boolean
284
318
  bad_test?: BadTest | null
319
+ bad_test_by?: BadTestSource
285
320
  sections?: string[]
286
321
  run_notes?: PromptRunNote[]
287
322
  }): string =>
@@ -303,7 +338,7 @@ export const rolePrompt = ({
303
338
  '## Acceptance criteria',
304
339
  ticket.criteria.map(({ id, text }) => `- ${id}: ${text}`).join('\n'),
305
340
  ...(role === 'test-writer' && bad_test
306
- ? [badTestSection({ bad_test })]
341
+ ? [badTestSection({ bad_test, by: bad_test_by ?? 'implementer' })]
307
342
  : []),
308
343
  ...(sections ?? []),
309
344
  // Recalled memories (#370) are added after these by the decision step.
@@ -175,6 +175,12 @@ export const BadTestSchema = z.object({
175
175
 
176
176
  export type BadTest = z.infer<typeof BadTestSchema>
177
177
 
178
+ /**
179
+ * Who sent a test back as bad: the implementer, who says it is wrong, or the
180
+ * engine, which found a mapped test that passes with no `expect()` (#581).
181
+ */
182
+ export type BadTestSource = 'implementer' | 'engine'
183
+
178
184
  /**
179
185
  * The implementer's result. `bad_test` sends a wrong test back;
180
186
  * `needs_setup_change` says the gates can't pass without a change to a test
@@ -20,6 +20,8 @@ import type { EngineConfig } from './engine-config'
20
20
  * one counts from the next prepare run.
21
21
  * - `test_timeout_ms`: how long each test command may run (#542). Each run
22
22
  * of a test command reads it then, so a new one counts from the next.
23
+ * - `test_case_timeout_ms`: how long one fast test may take (#580). Each run
24
+ * of a fast test command reads it then, so a new one counts from the next.
23
25
  * - `leftover_allow`: more new markdown the leftover scan lets through
24
26
  * (#520). Each scan reads it then, so a run stuck on a leftover can be
25
27
  * unstuck by allowing the file and resuming.
@@ -29,6 +31,7 @@ export const RELOADED_FIELDS = [
29
31
  'prepare_timeout_ms',
30
32
  'prepare_concurrency',
31
33
  'test_timeout_ms',
34
+ 'test_case_timeout_ms',
32
35
  'leftover_allow',
33
36
  ] as const
34
37
 
@@ -47,6 +50,10 @@ export const RELOADED_FIELDS = [
47
50
  * - `test_file_patterns`: which files are tests decides the red check, the
48
51
  * role rules, and which commits were the test-writer's.
49
52
  * - `test_setup_files`: part of what the tests are, as `checks` is.
53
+ * - `feature_map`: says which test files belong to which feature (#583), as
54
+ * `test_file_patterns` says which files are tests: the red check holds a
55
+ * ticket's new test files to it, and the role rules let the test-writer
56
+ * edit it.
50
57
  * - `rule_files`: the rules lens judges against them; tickets already
51
58
  * reviewed were judged on the old ones.
52
59
  * - `muninn`: the run's memory vault is fixed at start (`run_started.memory`).
@@ -59,6 +66,7 @@ export const FROZEN_FIELDS = [
59
66
  'checks',
60
67
  'test_file_patterns',
61
68
  'test_setup_files',
69
+ 'feature_map',
62
70
  'rule_files',
63
71
  'muninn',
64
72
  'run_budget_tokens',
@@ -94,6 +102,11 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
94
102
  from: positiveInt,
95
103
  to: positiveInt,
96
104
  }),
105
+ z.object({
106
+ field: z.literal('test_case_timeout_ms'),
107
+ from: positiveInt,
108
+ to: positiveInt,
109
+ }),
97
110
  z.object({
98
111
  field: z.literal('leftover_allow'),
99
112
  from: patterns,
@@ -140,6 +153,11 @@ export const configChanges = ({
140
153
  from: current.test_timeout_ms ?? null,
141
154
  to: next.test_timeout_ms ?? null,
142
155
  },
156
+ {
157
+ field: 'test_case_timeout_ms',
158
+ from: current.test_case_timeout_ms ?? null,
159
+ to: next.test_case_timeout_ms ?? null,
160
+ },
143
161
  {
144
162
  field: 'leftover_allow',
145
163
  from: current.leftover_allow ?? null,
@@ -173,6 +191,10 @@ const applyChange = ({
173
191
  return change.to === null
174
192
  ? omit(config, 'test_timeout_ms')
175
193
  : { ...config, test_timeout_ms: change.to }
194
+ case 'test_case_timeout_ms':
195
+ return change.to === null
196
+ ? omit(config, 'test_case_timeout_ms')
197
+ : { ...config, test_case_timeout_ms: change.to }
176
198
  case 'leftover_allow':
177
199
  return change.to === null
178
200
  ? omit(config, 'leftover_allow')
@@ -1,4 +1,4 @@
1
- import { join } from 'node:path'
1
+ import { isAbsolute, join, normalize } from 'node:path'
2
2
 
3
3
  import { z } from 'zod'
4
4
 
@@ -34,6 +34,12 @@ const TestCommandEntrySchema = z.union([
34
34
  }),
35
35
  ])
36
36
 
37
+ /** A test command setting: one command, or a list of them (see `testCommands`). */
38
+ const TestCommandsSchema = z.union([
39
+ z.string().min(1),
40
+ z.array(TestCommandEntrySchema),
41
+ ])
42
+
37
43
  /** The config's `muninn` block. */
38
44
  export const MuninnConfigSchema = z.object({
39
45
  /** The project's memory vault. */
@@ -49,6 +55,22 @@ export const DecisionModelConfigSchema = z.object({
49
55
  model: DecisionModelIdSchema.default(DEFAULT_DECISION_MODEL),
50
56
  })
51
57
 
58
+ /**
59
+ * Whether a config path names a file inside the repo (#583): not absolute,
60
+ * not ending in `/` (a folder), and, once cleaned (`normalize`), neither the
61
+ * repo root nor a path that climbs out of it with `..`.
62
+ */
63
+ const isFileInsideRepo = (path: string): boolean => {
64
+ const clean = normalize(path)
65
+ return (
66
+ !isAbsolute(path) &&
67
+ !path.endsWith('/') &&
68
+ clean !== '.' &&
69
+ clean !== '..' &&
70
+ !clean.startsWith('../')
71
+ )
72
+ }
73
+
52
74
  /**
53
75
  * The per-repo engine config: the gate commands, where tests live, the rule
54
76
  * files for the rules lens, the project's memory vault, the decision model,
@@ -59,14 +81,19 @@ export const DecisionModelConfigSchema = z.object({
59
81
  * The test command is optional here on purpose. A config without it still
60
82
  * loads, and intake refuses the run with a clear message instead.
61
83
  * `checks.test` is one command or a list of them (see `testCommands`); it is
62
- * kept as written.
84
+ * kept as written. `checks.slow_test` has the same shape (see
85
+ * `slowTestCommands`, #563).
63
86
  */
64
87
  export const EngineConfigSchema = z.object({
65
88
  checks: z
66
89
  .object({
67
- test: z
68
- .union([z.string().min(1), z.array(TestCommandEntrySchema)])
69
- .optional(),
90
+ test: TestCommandsSchema.optional(),
91
+ /**
92
+ * The slow tests (#563): the tests `test` leaves out, run only at
93
+ * the final review. Same shape as `test`. Left out, nothing
94
+ * changes.
95
+ */
96
+ slow_test: TestCommandsSchema.optional(),
70
97
  types: z.string().min(1).optional(),
71
98
  lint: z.string().min(1).optional(),
72
99
  })
@@ -92,12 +119,33 @@ export const EngineConfigSchema = z.object({
92
119
  * engine's default (`DEFAULT_COMMAND_TIMEOUT_MS`, 5 minutes).
93
120
  */
94
121
  test_timeout_ms: z.number().int().positive().optional(),
122
+ /**
123
+ * How long one fast test may take, in milliseconds (#580): passed to every
124
+ * `bun` command of `checks.test` as `--timeout`, and checked on a ticket's
125
+ * own test files by their measured time. Left out, no limit.
126
+ */
127
+ test_case_timeout_ms: z.number().int().positive().optional(),
95
128
  /**
96
129
  * Glob patterns for more new markdown the leftover scan lets through,
97
130
  * beyond doc folders, READMEs, and files the repo names (#520), such as
98
131
  * `design/*.md`; left out, none.
99
132
  */
100
133
  leftover_allow: z.array(z.string().min(1)).optional(),
134
+ /**
135
+ * A markdown file listing each feature and the test files that prove it
136
+ * (#583), relative to the repo root: a file inside the repo, so not an
137
+ * absolute path, not one that climbs out with `..`, and not a folder (no
138
+ * trailing `/`). The red check refuses a new test file it doesn't list,
139
+ * and the test-writer may edit it. Left out, no map.
140
+ */
141
+ feature_map: z
142
+ .string()
143
+ .min(1)
144
+ .refine(isFileInsideRepo, {
145
+ message:
146
+ 'must be a file inside the repo, relative to its root: not an absolute path, not one that leaves the repo with "..", and not a folder',
147
+ })
148
+ .optional(),
101
149
  test_file_patterns: z.array(z.string()).default(['**/*.test.ts']),
102
150
  test_setup_files: z.array(z.string()).default([]),
103
151
  rule_files: z.array(z.string()).default([]),
@@ -191,6 +239,19 @@ export const isBunTestCommand = (run: string): boolean =>
191
239
  const defaultResults = (run: string): TestResults =>
192
240
  isBunTestCommand(run) ? 'bun' : 'pass_fail'
193
241
 
242
+ /** Reads a test command setting (`checks.test` or `checks.slow_test`) into commands. */
243
+ const commandsOf = (value: EngineConfig['checks']['test']): TestCommand[] => {
244
+ if (value === undefined) return []
245
+ return (typeof value === 'string' ? [value] : value).map((entry) =>
246
+ typeof entry === 'string'
247
+ ? { run: entry, results: defaultResults(entry) }
248
+ : {
249
+ run: entry.run,
250
+ results: entry.results ?? defaultResults(entry.run),
251
+ }
252
+ )
253
+ }
254
+
194
255
  /**
195
256
  * Every test command in the config, in order, each with its results kind.
196
257
  * A missing `results` is `bun` for a `bun test` command, else `pass_fail`.
@@ -204,20 +265,76 @@ export const testCommands = ({
204
265
  config,
205
266
  }: {
206
267
  config: EngineConfig
207
- }): TestCommand[] => {
208
- const { test } = config.checks
209
- if (test === undefined) return []
210
- return (typeof test === 'string' ? [test] : test).map((entry) =>
211
- typeof entry === 'string'
212
- ? { run: entry, results: defaultResults(entry) }
213
- : {
214
- run: entry.run,
215
- results: entry.results ?? defaultResults(entry.run),
216
- }
217
- )
268
+ }): TestCommand[] => commandsOf(config.checks.test)
269
+
270
+ /**
271
+ * Every slow test command (#563), in order, each with its results kind, like
272
+ * `testCommands`. Empty when the config has none.
273
+ *
274
+ * @example
275
+ * slowTestCommands({ config: { ...config, checks: { test: 'bun test src', slow_test: 'bun test .e2e.test' } } })
276
+ * // [{ run: 'bun test .e2e.test', results: 'bun' }]
277
+ */
278
+ export const slowTestCommands = ({
279
+ config,
280
+ }: {
281
+ config: EngineConfig
282
+ }): TestCommand[] => commandsOf(config.checks.slow_test)
283
+
284
+ /** Whether the config has a slow test command (#563). */
285
+ export const hasSlowTests = ({ config }: { config: EngineConfig }): boolean =>
286
+ slowTestCommands({ config }).length > 0
287
+
288
+ /** The fast test speed limit (#580), or `null` with none. */
289
+ export const caseLimitOf = ({
290
+ config,
291
+ }: {
292
+ config: EngineConfig
293
+ }): number | null => config.test_case_timeout_ms ?? null
294
+
295
+ /**
296
+ * The feature map's path (#583), in the form a worktree-relative path takes
297
+ * everywhere else (`normalize`, no leading `./`), or `null` with no map.
298
+ * `./docs/feature-map.md` and `docs//feature-map.md` are both
299
+ * `docs/feature-map.md`. The role rules, the permission rules, the prompts,
300
+ * and the red check all read the map's path here, so they agree on it
301
+ * whatever way the config spells it.
302
+ *
303
+ * @example
304
+ * featureMapPath({ config: { ...config, feature_map: './docs/feature-map.md' } }) // 'docs/feature-map.md'
305
+ */
306
+ export const featureMapPath = ({
307
+ config,
308
+ }: {
309
+ config: EngineConfig
310
+ }): string | null =>
311
+ config.feature_map === undefined
312
+ ? null
313
+ : normalize(config.feature_map).replace(/^\.\//, '')
314
+
315
+ /**
316
+ * A fast `bun` test command with the speed limit (#580): `--timeout=<n>` at
317
+ * its end, or the command as written with no limit.
318
+ *
319
+ * @example
320
+ * fastBunCommand({ run: 'bun test src', config: { ...config, test_case_timeout_ms: 2000 } }) // 'bun test src --timeout=2000'
321
+ */
322
+ export const fastBunCommand = ({
323
+ run,
324
+ config,
325
+ }: {
326
+ run: string
327
+ config: EngineConfig
328
+ }): string => {
329
+ const limit = caseLimitOf({ config })
330
+ return limit === null ? run : `${run} --timeout=${limit}`
218
331
  }
219
332
 
220
- /** The test commands whose per-test results the engine reads. */
333
+ /**
334
+ * The test commands whose per-test results the engine reads, each with the
335
+ * fast test speed limit (#580) when the config has one. Used by the baseline,
336
+ * the red check and the already-done check; `testCommands` stays as written.
337
+ */
221
338
  export const bunTestCommands = ({
222
339
  config,
223
340
  }: {
@@ -225,7 +342,7 @@ export const bunTestCommands = ({
225
342
  }): string[] =>
226
343
  testCommands({ config })
227
344
  .filter(({ results }) => results === 'bun')
228
- .map(({ run }) => run)
345
+ .map(({ run }) => fastBunCommand({ run, config }))
229
346
 
230
347
  export type LoadEngineConfigResult =
231
348
  | { ok: true; config: EngineConfig }