@alecsibilia/luca 14.0.0-alpha.1 → 14.0.0-alpha.2

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 (36) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/board/package.json +1 -1
  3. package/board/server/board-vocabulary.ts +12 -1
  4. package/board/server/luca-version.ts +1 -1
  5. package/board/server/make-rows.ts +24 -2
  6. package/board/server/reduce-board.ts +41 -1
  7. package/engine/agents/role-instructions.ts +3 -1
  8. package/engine/agents/role-results.ts +39 -5
  9. package/engine/config/engine-config.ts +25 -0
  10. package/engine/core/already-done.ts +139 -0
  11. package/engine/core/decide-build.ts +83 -18
  12. package/engine/core/decide-usage.ts +5 -3
  13. package/engine/core/decide.ts +10 -4
  14. package/engine/core/execute-already-done.ts +65 -0
  15. package/engine/core/execute-build.ts +111 -26
  16. package/engine/core/execute-changeset.ts +5 -1
  17. package/engine/core/execute.ts +13 -1
  18. package/engine/core/learner-digest.ts +3 -1
  19. package/engine/core/not-in-run.ts +29 -0
  20. package/engine/core/pull-request-text.ts +45 -4
  21. package/engine/core/session-close.ts +10 -3
  22. package/engine/core/stuck-text.ts +4 -3
  23. package/engine/gates/gate-runner.ts +52 -7
  24. package/engine/gates/gate-schemas.ts +14 -0
  25. package/engine/gates/leftover-scan.ts +7 -2
  26. package/engine/git/git-adapter.ts +69 -4
  27. package/engine/git/path-text.ts +34 -0
  28. package/engine/guards/worktree-state.ts +14 -2
  29. package/engine/journal/journal-record.ts +62 -1
  30. package/engine/journal/replay.ts +41 -0
  31. package/engine/shell/run-command.ts +109 -7
  32. package/engine/testing/build-fixtures.ts +66 -0
  33. package/engine/tracker/github-tracker.ts +5 -0
  34. package/engine/tracker/in-memory-tracker.ts +3 -0
  35. package/engine/tracker/tracker.ts +5 -0
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.2
4
+
5
+ No changes in this release.
6
+
3
7
  ## 14.0.0-alpha.1
4
8
 
5
9
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.1",
3
+ "version": "14.0.0-alpha.2",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -128,7 +128,10 @@ export const BOARD_VOCABULARY = {
128
128
  })
129
129
  ),
130
130
  }),
131
- nothing_to_do: z.looseObject({}),
131
+ /** The run ends with nothing to do; `already_done` is empty at intake (#484). */
132
+ nothing_to_do: z.looseObject({
133
+ already_done: z.array(z.number().int()).catch([]).default([]),
134
+ }),
132
135
  spec_snapshot: z.looseObject({
133
136
  spec: z.looseObject({ number: z.number(), title: z.string() }),
134
137
  ticket_order: z.array(z.number()).catch([]),
@@ -313,6 +316,12 @@ export const BOARD_VOCABULARY = {
313
316
  ticket_skipped: z.looseObject({
314
317
  because: z.number().int().nullable().catch(null),
315
318
  }),
319
+ /** A ticket whose work was already on the base branch (#484), done by `shas`. */
320
+ ticket_already_done: z.looseObject({
321
+ shas: z.array(z.string()).catch([]),
322
+ }),
323
+ /** The engine closed an already-done ticket, as the run opens no PR. */
324
+ ticket_closed: z.looseObject({}),
316
325
 
317
326
  // The final review (#367). Its agent, gate, scan, commit, and push
318
327
  // records are the usual kinds with `ticket: null`.
@@ -428,6 +437,8 @@ const BoardEntrySchema = z.discriminatedUnion('kind', [
428
437
  entry({ kind: 'reply_ignored' }),
429
438
  entry({ kind: 'ticket_retried' }),
430
439
  entry({ kind: 'ticket_skipped' }),
440
+ entry({ kind: 'ticket_already_done' }),
441
+ entry({ kind: 'ticket_closed' }),
431
442
  entry({ kind: 'final_review_started' }),
432
443
  entry({ kind: 'lens_started' }),
433
444
  entry({ kind: 'lens_finished' }),
@@ -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.1"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.2"
@@ -11,6 +11,7 @@ import {
11
11
  runReasonText,
12
12
  reviewCountsText,
13
13
  roleWords,
14
+ shortSha,
14
15
  startKind,
15
16
  } from './reduce-board'
16
17
 
@@ -216,11 +217,16 @@ export const describeRecord = ({
216
217
  text: `Intake refused the run. ${after.run.refusal.join('; ')}`,
217
218
  tone: 'danger',
218
219
  })
219
- case 'nothing_to_do':
220
+ case 'nothing_to_do': {
221
+ const done = record.content.already_done
220
222
  return event({
221
- text: 'Nothing to do: the spec has no open tickets.',
223
+ text:
224
+ done.length === 0
225
+ ? 'Nothing to do: the spec has no open tickets.'
226
+ : `Nothing to do: the work of ${done.map((number) => `#${number}`).join(', ')} was already on the base branch.`,
222
227
  tone: 'info',
223
228
  })
229
+ }
224
230
  case 'spec_snapshot':
225
231
  return event({
226
232
  text: `Intake passed: spec #${record.content.spec.number}, ${record.content.ticket_order.length} tickets.`,
@@ -389,6 +395,16 @@ export const describeRecord = ({
389
395
  tone: 'info',
390
396
  })
391
397
  }
398
+ case 'ticket_already_done':
399
+ return event({
400
+ text: `${at}already done on the base branch, by ${record.content.shas.map((sha) => shortSha({ sha })).join(', ')}. Nothing to build.`,
401
+ tone: 'success',
402
+ })
403
+ case 'ticket_closed':
404
+ return event({
405
+ text: `${at}closed, since its work was already done.`,
406
+ tone: 'info',
407
+ })
392
408
  case 'ticket_skipped': {
393
409
  const { because } = record.content
394
410
  return event({
@@ -689,6 +705,12 @@ const finishedText = ({
689
705
  })
690
706
  }
691
707
  if (role === 'test-writer') {
708
+ if (result.outcome === 'already_done') {
709
+ return event({
710
+ text: `${at}the test-writer found the work already done.`,
711
+ tone: 'info',
712
+ })
713
+ }
692
714
  return result.outcome === 'nothing_new_to_test'
693
715
  ? event({ text: `${at}nothing new to test.`, tone: 'info' })
694
716
  : event({ text: `${at}tests written.`, tone: 'info' })
@@ -40,7 +40,7 @@ const STUCK_REASONS: Record<string, string> = {
40
40
  agent_failed: 'An agent failed on its last try.',
41
41
  red_check_failed: 'The red check failed.',
42
42
  nothing_new_to_test:
43
- 'The test-writer found nothing new to test. If the ticket changes no behavior, label it refactor and start the run again.',
43
+ 'The test-writer says the ticket changes no behavior, so there is nothing new to test. Label it refactor and retry it.',
44
44
  leftovers_found:
45
45
  'The leftover scan found files that must not be committed.',
46
46
  gates_failed: 'The checks failed.',
@@ -55,6 +55,24 @@ const STUCK_REASONS: Record<string, string> = {
55
55
  crashed: 'The engine crashed in the same step again and again.',
56
56
  }
57
57
 
58
+ /**
59
+ * A commit's short sha, as git and GitHub show it.
60
+ *
61
+ * @example
62
+ * shortSha({ sha: '3559c25f5a1b' }) // '3559c25'
63
+ */
64
+ export const shortSha = ({ sha }: { sha: string }): string => sha.slice(0, 7)
65
+
66
+ /**
67
+ * An already-done ticket's activity (#484): calm, with the commits that
68
+ * did its work.
69
+ *
70
+ * @example
71
+ * alreadyDoneText({ shas: ['3559c25f5a1b'] }) // 'already done · 3559c25'
72
+ */
73
+ export const alreadyDoneText = ({ shas }: { shas: string[] }): string =>
74
+ ['already done', ...shas.map((sha) => shortSha({ sha }))].join(' · ')
75
+
58
76
  /** Why a retry of an edited ticket was refused, in words. */
59
77
  export const RETRY_REFUSED_TEXT =
60
78
  "Its new text or labels aren't ready to build."
@@ -376,6 +394,8 @@ const STEP_WORDS: Record<string, string> = {
376
394
  push_final_fixes: 'pushing the run branch',
377
395
  write_changeset: 'writing the changeset',
378
396
  open_pull_request: 'opening the pull request',
397
+ mark_already_done: 'noting the work is already done',
398
+ close_ticket: 'closing the ticket',
379
399
  }
380
400
 
381
401
  /**
@@ -1275,6 +1295,23 @@ const applyKind = ({
1275
1295
  ticket,
1276
1296
  because: record.content.because,
1277
1297
  })
1298
+ // Done, not stuck (#484): its work was already on the base branch.
1299
+ case 'ticket_already_done':
1300
+ return updateTicket({
1301
+ state,
1302
+ number: ticket,
1303
+ update: (card) => ({
1304
+ ...card,
1305
+ stage: 'done',
1306
+ step: ALL_STEPS_DONE,
1307
+ role: null,
1308
+ open_check: null,
1309
+ activity: alreadyDoneText({ shas: record.content.shas }),
1310
+ }),
1311
+ })
1312
+ // Its closing changes nothing the board shows.
1313
+ case 'ticket_closed':
1314
+ return state
1278
1315
  case 'ticket_retried':
1279
1316
  return ticketRetried({ state, record })
1280
1317
  // An ignored reply is only a chat row; the engine answered it.
@@ -1577,6 +1614,9 @@ const agentFinished = ({
1577
1614
  if (card.open_check === 'review') {
1578
1615
  return { ...done, activity: 'test findings answered' }
1579
1616
  }
1617
+ if (result.outcome === 'already_done') {
1618
+ return { ...done, activity: 'already done' }
1619
+ }
1580
1620
  return result.outcome === 'nothing_new_to_test'
1581
1621
  ? { ...done, step: 2, activity: 'nothing new to test' }
1582
1622
  : { ...done, step: 1, activity: 'red check' }
@@ -82,8 +82,10 @@ You write the failing tests for ONE ticket, before any code exists.
82
82
  - 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.
83
83
 
84
84
  Your result (structured output):
85
- - outcome: "tests_written", or "nothing_new_to_test" if the ticket truly changes no behavior (a refactor), or "needs_setup_change" if the tests can't work without a change to a test setup file${setupFiles({ config })} (you may never change one): say which file and why in "setup_change", and the user makes the change.
85
+ - outcome: "tests_written", or "nothing_new_to_test" if the ticket truly changes no behavior (a refactor), or "already_done" if the ticket's work is already on the base branch, or "needs_setup_change" if the tests can't work without a change to a test setup file${setupFiles({ config })} (you may never change one): say which file and why in "setup_change", and the user makes the change.
86
+ - Answer "already_done" only when EVERY acceptance criterion is already met on the base branch and already has a test that checks it. Look with \`git log\` and \`git show\`, and read the code and tests. Then write no tests, and give your evidence: in "done_by", the commits that did the work, as { sha, title }; in "criteria", the existing tests that cover each criterion. If even one criterion is not met, or not tested, write the tests instead.
86
87
  - criteria: for EACH criterion id (AC1, AC2, ...), the tests that check it, as { file, name }. "name" is the full name bun prints: describe names and the test name joined by " > ".
88
+ - done_by: empty, unless the outcome is "already_done".
87
89
  - finding_responses: empty, unless your prompt gives you ticket review findings (see below).
88
90
  - summary, assumptions, run_notes.
89
91
 
@@ -126,21 +126,39 @@ const SHARED_FIELDS = {
126
126
  run_notes: z.array(z.string()).default([]),
127
127
  }
128
128
 
129
+ /** A commit on the base branch that did a ticket's work (#484). */
130
+ export const DoneCommitSchema = z.object({
131
+ sha: z.string().min(1),
132
+ /** Its subject line, if the agent read it. */
133
+ title: z.string().default(''),
134
+ })
135
+
136
+ export type DoneCommit = z.infer<typeof DoneCommitSchema>
137
+
129
138
  /**
130
139
  * The test-writer's result. `nothing_new_to_test` is an honest answer for a
131
140
  * ticket that changes no behavior; the engine then makes the ticket stuck,
132
- * with a hint to label it `refactor`. `needs_setup_change` says the tests
133
- * can't be written without a change to a test setup file (`setup_change`),
134
- * which only the user may make.
141
+ * with a hint to label it `refactor`. `already_done` says the ticket's work
142
+ * is already on the base branch (#484): `done_by` names the commits that did
143
+ * it and `criteria` the existing tests that cover each criterion; the engine
144
+ * counts the ticket as done. `needs_setup_change` says the tests can't be
145
+ * written without a change to a test setup file (`setup_change`), which
146
+ * only the user may make.
135
147
  */
136
148
  export const TestWriterResultSchema = z.object({
137
149
  outcome: z.enum([
138
150
  'tests_written',
139
151
  'nothing_new_to_test',
152
+ 'already_done',
140
153
  'needs_setup_change',
141
154
  ]),
142
- /** For each criterion id (AC1, AC2, ...), the tests that check it. */
155
+ /**
156
+ * For each criterion id (AC1, AC2, ...), the tests that check it: new
157
+ * ones, or with `already_done`, the ones already on the base branch.
158
+ */
143
159
  criteria: z.array(CriterionTestsSchema).default([]),
160
+ /** With `already_done`: the base branch's commits that did the work. */
161
+ done_by: z.array(DoneCommitSchema).default([]),
144
162
  ...SETUP_FIELDS,
145
163
  ...SHARED_FIELDS,
146
164
  ...FIXER_FIELDS,
@@ -317,8 +335,24 @@ export const parseRoleResult = ({
317
335
  error: `The ${role}'s result does not fit its schema:\n${z.prettifyError(parsed.error)}`,
318
336
  }
319
337
  }
320
- // Every reviewer (the ticket reviewer and each lens) gives a verdict.
321
338
  const { result } = parsed.data
339
+ // "Already done" is only an answer with its evidence (#484).
340
+ if (parsed.data.role === 'test-writer') {
341
+ const { outcome, done_by, criteria } = parsed.data.result
342
+ if (outcome === 'already_done' && done_by.length === 0) {
343
+ return {
344
+ ok: false,
345
+ error: 'The test-writer\'s "already_done" names no commit: list in "done_by" the base branch\'s commits that did the work.',
346
+ }
347
+ }
348
+ if (outcome === 'already_done' && criteria.length === 0) {
349
+ return {
350
+ ok: false,
351
+ error: 'The test-writer\'s "already_done" names no tests: list in "criteria" the existing tests that cover each criterion.',
352
+ }
353
+ }
354
+ }
355
+ // Every reviewer (the ticket reviewer and each lens) gives a verdict.
322
356
  if ('verdict' in result) {
323
357
  const { verdict, findings } = result
324
358
  const expected = findings.some(isBlocking)
@@ -60,6 +60,11 @@ export const EngineConfigSchema = z.object({
60
60
  * that are gitignored, such as `bun run build:rom`. Agents never run it.
61
61
  */
62
62
  prepare: z.string().min(1).optional(),
63
+ /**
64
+ * How long the prepare command may run, in milliseconds; left out, the
65
+ * engine's default (`DEFAULT_PREPARE_TIMEOUT_MS`, 30 minutes).
66
+ */
67
+ prepare_timeout_ms: z.number().int().positive().optional(),
63
68
  test_file_patterns: z.array(z.string()).default(['**/*.test.ts']),
64
69
  test_setup_files: z.array(z.string()).default([]),
65
70
  rule_files: z.array(z.string()).default([]),
@@ -73,6 +78,26 @@ export const EngineConfigSchema = z.object({
73
78
 
74
79
  export type EngineConfig = z.infer<typeof EngineConfigSchema>
75
80
 
81
+ /**
82
+ * How long the prepare command may run when the config doesn't say: 30
83
+ * minutes. A first build in a fresh worktree can take many minutes
84
+ * (HeartGold's takes about 8), far past the 5 minutes other commands get.
85
+ */
86
+ export const DEFAULT_PREPARE_TIMEOUT_MS = 30 * 60 * 1000
87
+
88
+ /**
89
+ * The prepare command's time limit: the config's `prepare_timeout_ms`, else
90
+ * the default.
91
+ *
92
+ * @example
93
+ * prepareTimeoutOf({ config: { ...config, prepare_timeout_ms: 3_600_000 } }) // 3_600_000
94
+ */
95
+ export const prepareTimeoutOf = ({
96
+ config,
97
+ }: {
98
+ config: EngineConfig
99
+ }): number => config.prepare_timeout_ms ?? DEFAULT_PREPARE_TIMEOUT_MS
100
+
76
101
  /** One test command the engine runs, and how it reads the results. */
77
102
  export type TestCommand = { run: string; results: TestResults }
78
103
 
@@ -0,0 +1,139 @@
1
+ import type {
2
+ CriterionTests,
3
+ DoneCommit,
4
+ TestWriterResult,
5
+ } from '../agents/role-results'
6
+ import type { TicketSnapshot } from '../intake/intake-schemas'
7
+
8
+ /**
9
+ * A ticket whose work is already on the base branch (#484): its
10
+ * test-writer answered `already_done`, with the commits that did the work
11
+ * and the tests that already cover each criterion. The ticket is done, not
12
+ * stuck, and nobody has to reply. The run's PR closes it; a run with no PR
13
+ * closes it itself, and a run where every ticket was already done ends with
14
+ * nothing to do.
15
+ */
16
+ export type AlreadyDoneAction =
17
+ /**
18
+ * Tell the spec issue (`body`) that the ticket's work is already done by
19
+ * `shas`, then journal the ticket as done.
20
+ */
21
+ | {
22
+ type: 'mark_already_done'
23
+ ticket: number
24
+ spec_number: number
25
+ shas: string[]
26
+ body: string
27
+ }
28
+ /** Close an already-done ticket, with `body` as a comment on it. */
29
+ | { type: 'close_ticket'; ticket: number; body: string }
30
+ /**
31
+ * End the run with nothing to do: at intake (`already_done` is empty),
32
+ * or once every ticket's work was already done and those are closed.
33
+ */
34
+ | {
35
+ type: 'finish_nothing_to_do'
36
+ closed_tickets: number[]
37
+ already_done: number[]
38
+ }
39
+
40
+ /** How many characters of a sha the engine shows. */
41
+ const SHORT_SHA = 7
42
+
43
+ /**
44
+ * A commit's short sha, as git and GitHub show it.
45
+ *
46
+ * @example
47
+ * shortSha({ sha: '3559c25f5a1b2c3d' }) // '3559c25'
48
+ */
49
+ export const shortSha = ({ sha }: { sha: string }): string =>
50
+ sha.slice(0, SHORT_SHA)
51
+
52
+ /** The commits in words: `3559c25`, or `3559c25 (feat: add sum)`. */
53
+ const commitsText = ({ done_by }: { done_by: DoneCommit[] }): string =>
54
+ done_by
55
+ .map(({ sha, title }) =>
56
+ title === '' ? shortSha({ sha }) : `${shortSha({ sha })} (${title})`
57
+ )
58
+ .join(', ')
59
+
60
+ const testsText = ({ criteria }: { criteria: CriterionTests[] }): string[] =>
61
+ criteria.flatMap(({ criterion_id, tests }) =>
62
+ tests.map(({ file, name }) => `- ${criterion_id}: ${file} > ${name}`)
63
+ )
64
+
65
+ /**
66
+ * The step that marks a ticket already done, from its test-writer's
67
+ * `already_done`: the comment on the spec issue names the commits that
68
+ * did the work and the tests that cover each criterion, and says nobody
69
+ * has to reply.
70
+ *
71
+ * @example
72
+ * alreadyDoneStep({ spec_number: 10, base_branch: 'main', ticket, result })
73
+ * // { type: 'mark_already_done', ticket: 11, spec_number: 10, shas: ['3559c25f5...'], body: '**#11 is already done: Add sum** ...' }
74
+ */
75
+ export const alreadyDoneStep = ({
76
+ spec_number,
77
+ base_branch,
78
+ ticket,
79
+ result,
80
+ }: {
81
+ spec_number: number
82
+ base_branch: string
83
+ ticket: TicketSnapshot
84
+ result: TestWriterResult
85
+ }): AlreadyDoneAction => {
86
+ const tests = testsText({ criteria: result.criteria })
87
+ const body = [
88
+ `**#${ticket.number} is already done: ${ticket.title}**`,
89
+ `The test-writer found this ticket's work already on \`${base_branch}\`, so this run builds nothing for it. It is done, not stuck: you don't need to reply.`,
90
+ `Done by: ${commitsText({ done_by: result.done_by })}`,
91
+ tests.length === 0
92
+ ? ''
93
+ : `Tests that already cover it:\n\n${tests.join('\n')}`,
94
+ result.summary === '' ? '' : `The test-writer says: ${result.summary}`,
95
+ "This run's PR closes it. If the run opens no PR, the engine closes it.",
96
+ ]
97
+ return {
98
+ type: 'mark_already_done',
99
+ ticket: ticket.number,
100
+ spec_number,
101
+ shas: result.done_by.map(({ sha }) => sha),
102
+ body: body.filter((part) => part !== '').join('\n\n'),
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The step that closes an already-done ticket in a run with no PR, with a
108
+ * comment saying which commits did its work.
109
+ *
110
+ * @example
111
+ * closeTicketStep({ spec_number: 10, base_branch: 'main', ticket: 11, shas })
112
+ * // { type: 'close_ticket', ticket: 11, body: "Closing: this ticket's work is already on `main` ..." }
113
+ */
114
+ export const closeTicketStep = ({
115
+ spec_number,
116
+ base_branch,
117
+ ticket,
118
+ shas,
119
+ }: {
120
+ spec_number: number
121
+ base_branch: string
122
+ ticket: number
123
+ shas: string[]
124
+ }): AlreadyDoneAction => ({
125
+ type: 'close_ticket',
126
+ ticket,
127
+ body:
128
+ `Closing: this ticket's work is already on \`${base_branch}\`, done by ${shas.map((sha) => shortSha({ sha })).join(', ')}. ` +
129
+ `Luca's run for spec #${spec_number} found nothing left to build for it, so it opens no PR for it.`,
130
+ })
131
+
132
+ /**
133
+ * An already-done ticket's line in the PR's ticket list, after its title.
134
+ *
135
+ * @example
136
+ * alreadyDoneNote({ shas: ['3559c25f5a1b'] }) // ' (already done before this run, by 3559c25)'
137
+ */
138
+ export const alreadyDoneNote = ({ shas }: { shas: string[] }): string =>
139
+ ` (already done before this run, by ${shas.map((sha) => shortSha({ sha })).join(', ')})`