@alecsibilia/luca 14.0.0-alpha.13 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/board/client/board-look.ts +4 -0
  3. package/board/client/board-panel.tsx +26 -4
  4. package/board/package.json +1 -1
  5. package/board/server/luca-version.ts +1 -1
  6. package/board/server/make-rows.ts +15 -3
  7. package/board/server/reduce-board.ts +100 -25
  8. package/board/shared/board-state.ts +10 -0
  9. package/engine/agents/role-instructions.ts +46 -5
  10. package/engine/agents/role-prompts.ts +46 -10
  11. package/engine/agents/role-results.ts +6 -0
  12. package/engine/cli/run-modes.ts +5 -2
  13. package/engine/config/config-reload.ts +47 -5
  14. package/engine/config/engine-config.ts +141 -18
  15. package/engine/core/decide-build.ts +87 -10
  16. package/engine/core/decide-final-review.ts +21 -1
  17. package/engine/core/execute-build.ts +209 -34
  18. package/engine/core/execute-final-review.ts +10 -0
  19. package/engine/core/execute.ts +6 -2
  20. package/engine/core/loop-caps.ts +10 -2
  21. package/engine/core/stuck-text.ts +50 -6
  22. package/engine/gates/feature-map.ts +28 -0
  23. package/engine/gates/gate-runner.ts +271 -36
  24. package/engine/gates/gate-schemas.ts +41 -2
  25. package/engine/gates/leftover-scan.ts +111 -42
  26. package/engine/gates/new-tests.ts +45 -0
  27. package/engine/gates/red-check.ts +113 -3
  28. package/engine/gates/test-runner.ts +65 -2
  29. package/engine/git/git-adapter.ts +14 -0
  30. package/engine/guards/role-rules.ts +14 -5
  31. package/engine/index.ts +2 -0
  32. package/engine/jev/jev-jobs.ts +62 -30
  33. package/engine/jev/jev-schemas.ts +37 -0
  34. package/engine/jev/jev-shadow.ts +8 -3
  35. package/engine/journal/journal-record.ts +35 -3
  36. package/engine/journal/replay.ts +126 -21
  37. package/engine/shell/run-command.ts +21 -6
  38. package/engine/testing/build-fixtures.ts +16 -3
  39. package/engine/testing/final-review-fixtures.ts +40 -1
  40. package/engine/testing/practice-run.ts +1 -1
  41. package/package.json +1 -1
  42. package/skills/luca-retro/SKILL.md +3 -3
  43. package/skills/luca-retro/scripts/retro-summary.ts +24 -21
  44. package/skills/luca-unstick/SKILL.md +4 -3
  45. package/skills/luca-unstick/scripts/stuck-summary.ts +32 -20
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.15
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.14
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.13
4
12
 
5
13
  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.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.13"
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}.`,
@@ -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 {
@@ -52,7 +53,8 @@ const STUCK_REASONS: Record<string, string> = {
52
53
  leftovers_found:
53
54
  'The leftover scan found files that must not be committed.',
54
55
  gates_failed: 'The checks failed.',
55
- 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.',
56
58
  changes_requested: 'The ticket review asked for changes.',
57
59
  join_failed: "The ticket couldn't join the run branch.",
58
60
  join_gates_failed: 'The checks failed after the ticket joined.',
@@ -436,6 +438,7 @@ const STEP_WORDS: Record<string, string> = {
436
438
  run_red_check: 'running the red check',
437
439
  run_gates: 'running the checks',
438
440
  run_final_gates: 'running the checks',
441
+ run_final_slow_tests: 'running the slow tests',
439
442
  join_run_branch: 'joining the run branch',
440
443
  push_run_branch: 'pushing the run branch',
441
444
  push_final_fixes: 'pushing the run branch',
@@ -494,9 +497,40 @@ const withoutSteps = ({ state }: { state: BoardState }): BoardState =>
494
497
  run_steps: [],
495
498
  })
496
499
 
500
+ /** The stages a step doesn't move a ticket out of. */
501
+ const SETTLED_STAGES = new Set<TicketStage>(['stuck', 'done', 'skipped'])
502
+
497
503
  /**
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.
504
+ * The stage a ticket's step puts it in (#549): the red check and the checks
505
+ * are testing, but the checks after joining are still joining; joining and
506
+ * pushing the run branch are joining. Any other step, or a stuck, done or
507
+ * skipped ticket, keeps its stage.
508
+ */
509
+ const stepStage = ({
510
+ card,
511
+ step,
512
+ }: {
513
+ card: TicketCard
514
+ step: string
515
+ }): TicketStage => {
516
+ if (SETTLED_STAGES.has(card.stage)) return card.stage
517
+ switch (step) {
518
+ case 'run_red_check':
519
+ return 'testing'
520
+ case 'run_gates':
521
+ return card.stage === 'joining' ? 'joining' : 'testing'
522
+ case 'join_run_branch':
523
+ case 'push_run_branch':
524
+ return 'joining'
525
+ default:
526
+ return card.stage
527
+ }
528
+ }
529
+
530
+ /**
531
+ * A step began: it is its ticket's current step, or one of the run's, and
532
+ * it sets the ticket's stage. A new step on the same key (a crash's redo)
533
+ * replaces the one before it.
500
534
  */
501
535
  const stepStarted = ({
502
536
  state,
@@ -506,7 +540,7 @@ const stepStarted = ({
506
540
  record: Extract<BoardRecord, { kind: 'step_started' }>
507
541
  }): BoardState => {
508
542
  // A replayed journal of an engine that ended has no step running.
509
- if (state.run.engine_ended !== null) return state
543
+ const ended = state.run.engine_ended !== null
510
544
  const current_step = {
511
545
  text: stepText({ step: record.content.step }),
512
546
  since: record.time,
@@ -515,9 +549,14 @@ const stepStarted = ({
515
549
  return updateTicket({
516
550
  state,
517
551
  number: record.ticket,
518
- update: (card) => ({ ...card, current_step }),
552
+ update: (card) => ({
553
+ ...card,
554
+ stage: stepStage({ card, step: record.content.step }),
555
+ current_step: ended ? card.current_step : current_step,
556
+ }),
519
557
  })
520
558
  }
559
+ if (ended) return state
521
560
  const { key } = record.content
522
561
  return withRunSteps({
523
562
  state,
@@ -1096,6 +1135,7 @@ const applyKind = ({
1096
1135
  number: ticket,
1097
1136
  update: (card) => ({
1098
1137
  ...card,
1138
+ stage: 'building',
1099
1139
  step: 0,
1100
1140
  fix_round: 0,
1101
1141
  open_check: null,
@@ -1177,11 +1217,13 @@ const applyKind = ({
1177
1217
  return updateTicket({
1178
1218
  state,
1179
1219
  number: ticket,
1220
+ // Joined, it is done once its checks there pass and the run
1221
+ // branch is pushed.
1180
1222
  update: (card) =>
1181
1223
  record.content.ok
1182
1224
  ? {
1183
1225
  ...card,
1184
- stage: 'done',
1226
+ stage: 'joining',
1185
1227
  step: ALL_STEPS_DONE,
1186
1228
  role: null,
1187
1229
  activity: 'joined',
@@ -1203,6 +1245,7 @@ const applyKind = ({
1203
1245
  number: ticket,
1204
1246
  update: (card) => ({
1205
1247
  ...card,
1248
+ stage: 'fixing',
1206
1249
  step: 0,
1207
1250
  role: null,
1208
1251
  activity: 'updating tests for the run branch',
@@ -1217,8 +1260,8 @@ const applyKind = ({
1217
1260
  state,
1218
1261
  number: ticket,
1219
1262
  update: (card) =>
1220
- card.stage === 'done'
1221
- ? { ...card, activity: 'pushed' }
1263
+ card.stage === 'joining' || card.stage === 'done'
1264
+ ? { ...card, stage: 'done', activity: 'pushed' }
1222
1265
  : card,
1223
1266
  })
1224
1267
  case 'ticket_stuck': {
@@ -1492,7 +1535,16 @@ const applyKind = ({
1492
1535
  fix_round: record.content.round,
1493
1536
  tried: withTried({
1494
1537
  tried: review.tried,
1495
- 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
+ }`,
1496
1548
  }),
1497
1549
  }),
1498
1550
  })
@@ -1553,7 +1605,7 @@ const OPEN_CHECK_TEXT = {
1553
1605
  /**
1554
1606
  * A fixer's start in a review fix round. The round is the review's round (a
1555
1607
  * 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.
1608
+ * fixer adds its "tried" line. The card is fixing, at its step.
1557
1609
  */
1558
1610
  const reviewFixStarted = ({
1559
1611
  card,
@@ -1570,7 +1622,7 @@ const reviewFixStarted = ({
1570
1622
  ...card,
1571
1623
  started: true,
1572
1624
  role,
1573
- stage: 'reviewing',
1625
+ stage: 'fixing',
1574
1626
  review_fix_round: round,
1575
1627
  activity: `fixing the review's findings (${round}/${LOOP_CAP})`,
1576
1628
  tried:
@@ -1613,15 +1665,19 @@ const agentStarted = ({
1613
1665
  })
1614
1666
  : card.tried
1615
1667
  const fixing = kind === 'fix' || kind === 'resent'
1668
+ // A writer answering an open check, or fixing on top of the run
1669
+ // branch, is fixing; its first pass is building.
1670
+ const stage =
1671
+ card.open_check !== null || card.stage === 'fixing'
1672
+ ? 'fixing'
1673
+ : 'building'
1616
1674
  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'
1675
+ // A retried review fixer stays at its step.
1620
1676
  return {
1621
1677
  ...started,
1622
1678
  tried,
1623
- stage: reviewing ? 'reviewing' : 'building',
1624
- step: reviewing ? card.step : 0,
1679
+ stage,
1680
+ step: card.open_check === 'review' ? card.step : 0,
1625
1681
  activity: fixing
1626
1682
  ? `fixing tests (${fix_round}/${LOOP_CAP})`
1627
1683
  : kind === 'retry'
@@ -1633,8 +1689,7 @@ const agentStarted = ({
1633
1689
  return {
1634
1690
  ...started,
1635
1691
  tried,
1636
- stage:
1637
- card.stage === 'reviewing' ? 'reviewing' : 'building',
1692
+ stage,
1638
1693
  step: 2,
1639
1694
  activity: fixing
1640
1695
  ? `fixing (${fix_round}/${LOOP_CAP})`
@@ -1724,9 +1779,12 @@ const agentFinished = ({
1724
1779
  }),
1725
1780
  done.tried
1726
1781
  )
1782
+ // Approved, it joins the run branch next; else a review fix
1783
+ // round follows.
1727
1784
  return result.verdict === 'approve'
1728
1785
  ? {
1729
1786
  ...done,
1787
+ stage: 'joining',
1730
1788
  step: ALL_STEPS_DONE,
1731
1789
  findings,
1732
1790
  open_check: null,
@@ -1735,6 +1793,7 @@ const agentFinished = ({
1735
1793
  }
1736
1794
  : {
1737
1795
  ...done,
1796
+ stage: 'fixing',
1738
1797
  findings,
1739
1798
  open_check: 'review',
1740
1799
  tried: ruled,
@@ -1759,6 +1818,15 @@ const gatesRun = ({
1759
1818
  .map((check) => check.name)
1760
1819
  .join(', ')
1761
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
+ }
1762
1830
  // The final review's fixes are checked on the run branch, with no ticket.
1763
1831
  if (record.ticket === null) {
1764
1832
  return ok
@@ -1828,9 +1896,9 @@ export const testsSentBackText = ({
1828
1896
  }
1829
1897
 
1830
1898
  /**
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.
1899
+ * A ticket sent back onto the run branch is fixed on top of it: its tests
1900
+ * first if test files clashed, else its code; then the checks and a fresh
1901
+ * review, whose fix rounds count from zero again.
1834
1902
  */
1835
1903
  const ticketRebased = ({
1836
1904
  state,
@@ -1844,7 +1912,7 @@ const ticketRebased = ({
1844
1912
  number: record.ticket,
1845
1913
  update: (card) => ({
1846
1914
  ...card,
1847
- stage: 'building',
1915
+ stage: 'fixing',
1848
1916
  step: record.content.tests.length > 0 ? 0 : 2,
1849
1917
  role: null,
1850
1918
  fix_round: 0,
@@ -2054,11 +2122,18 @@ export const unfinishedBlockers = ({
2054
2122
  return blocker !== undefined && blocker.stage !== 'done'
2055
2123
  })
2056
2124
 
2125
+ /** The stages of a ticket the engine is working on. */
2126
+ const ACTIVE_STAGES = new Set<TicketStage>([
2127
+ 'building',
2128
+ 'reviewing',
2129
+ 'testing',
2130
+ 'fixing',
2131
+ 'joining',
2132
+ ])
2133
+
2057
2134
  const isActive = ({ state }: { state: BoardState }): boolean =>
2058
2135
  state.tickets.some(
2059
- (ticket) =>
2060
- ticket.started &&
2061
- (ticket.stage === 'building' || ticket.stage === 'reviewing')
2136
+ (ticket) => ticket.started && ACTIVE_STAGES.has(ticket.stage)
2062
2137
  ) ||
2063
2138
  state.final_review.state === 'reviewing' ||
2064
2139
  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',
@@ -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
@@ -203,8 +233,9 @@ export const CHANGESETS_SECTION = [
203
233
  ].join('\n\n')
204
234
 
205
235
  /**
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.
236
+ * The section every reviewer (the ticket-reviewer and each lens) gets in a
237
+ * repo with changesets, so a rule like "every PR needs a changeset" raises
238
+ * no finding.
208
239
  */
209
240
  export const CHANGESETS_LENS_SECTION = [
210
241
  '## The engine writes the changeset',
@@ -218,8 +249,8 @@ const LENS_TASK_TEXT =
218
249
 
219
250
  const ROLE_TASKS: Record<AgentRole, string> = {
220
251
  'test-writer':
221
- 'Write failing tests for every acceptance criterion below. Edit test files only. ' +
222
- '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.',
223
254
  implementer:
224
255
  'Make every gate pass. Never edit a test file. ' +
225
256
  'If a test is wrong, answer "bad_test" with your reason. ' +
@@ -258,7 +289,10 @@ export const roleTask = ({ role }: { role: AgentRole }): string =>
258
289
  * @param refactor - The ticket is a refactor ticket: the implementer gets
259
290
  * the refactor task (no new behavior; it may follow renames into tests).
260
291
  * @param bad_test - For a fresh test-writer after a bad-test bounce: the
261
- * 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.
262
296
  * @param sections - More sections for the end, such as a reviewer's diff
263
297
  * and gate results, or a review fixer's findings.
264
298
  * @param run_notes - Notes earlier agents in the run left, oldest first,
@@ -273,6 +307,7 @@ export const rolePrompt = ({
273
307
  ticket,
274
308
  refactor,
275
309
  bad_test,
310
+ bad_test_by,
276
311
  sections,
277
312
  run_notes,
278
313
  }: {
@@ -281,6 +316,7 @@ export const rolePrompt = ({
281
316
  ticket: TicketSnapshot
282
317
  refactor?: boolean
283
318
  bad_test?: BadTest | null
319
+ bad_test_by?: BadTestSource
284
320
  sections?: string[]
285
321
  run_notes?: PromptRunNote[]
286
322
  }): string =>
@@ -302,7 +338,7 @@ export const rolePrompt = ({
302
338
  '## Acceptance criteria',
303
339
  ticket.criteria.map(({ id, text }) => `- ${id}: ${text}`).join('\n'),
304
340
  ...(role === 'test-writer' && bad_test
305
- ? [badTestSection({ bad_test })]
341
+ ? [badTestSection({ bad_test, by: bad_test_by ?? 'implementer' })]
306
342
  : []),
307
343
  ...(sections ?? []),
308
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
@@ -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`