@alecsibilia/luca 14.0.0-alpha.25 → 14.0.0-alpha.27

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 (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/board/package.json +1 -1
  4. package/board/server/luca-version.ts +1 -1
  5. package/engine/agents/agent-launcher.ts +10 -1
  6. package/engine/agents/claude-messages.ts +2 -0
  7. package/engine/agents/claude-options.ts +7 -18
  8. package/engine/agents/launch-session.ts +2 -0
  9. package/engine/agents/refactor-update-prompts.ts +62 -0
  10. package/engine/agents/role-turns.ts +23 -0
  11. package/engine/agents/scripted-launcher.ts +4 -0
  12. package/engine/agents/session-pool.ts +1 -0
  13. package/engine/config/engine-config-schema.ts +80 -40
  14. package/engine/config/engine-config.ts +3 -0
  15. package/engine/config/test-commands.ts +35 -0
  16. package/engine/core/agent-turn.ts +13 -1
  17. package/engine/core/build-action.ts +17 -0
  18. package/engine/core/build-launch.ts +29 -9
  19. package/engine/core/decide-final-review.ts +30 -12
  20. package/engine/core/decide-red-green.ts +7 -5
  21. package/engine/core/decide-refactor-update.ts +109 -0
  22. package/engine/core/evidence-text.ts +13 -3
  23. package/engine/core/execute-build.ts +8 -0
  24. package/engine/core/execute-final-review.ts +2 -1
  25. package/engine/core/execute-red-check.ts +21 -0
  26. package/engine/core/execute-refactor-update.ts +107 -0
  27. package/engine/core/final-review-action.ts +11 -2
  28. package/engine/core/session-close.ts +10 -2
  29. package/engine/gates/gate-runner.ts +29 -4
  30. package/engine/gates/gate-schemas.ts +4 -1
  31. package/engine/gates/own-slow-check.ts +147 -0
  32. package/engine/gates/red-check.ts +44 -0
  33. package/engine/index.ts +1 -1
  34. package/engine/journal/final-review-replay.ts +17 -1
  35. package/engine/journal/final-review-state.ts +10 -0
  36. package/engine/journal/journal-entry-schema.ts +6 -0
  37. package/engine/journal/journal-kinds.ts +2 -0
  38. package/engine/journal/journal-record-schema.ts +6 -0
  39. package/engine/journal/refactor-update-entries.ts +45 -0
  40. package/engine/journal/ticket-progress.ts +32 -0
  41. package/engine/journal/ticket-replay.ts +18 -0
  42. package/engine/journal/ticket-result-replay.ts +36 -1
  43. package/engine/journal/ticket-retry-replay.ts +5 -2
  44. package/package.json +1 -1
  45. package/skills/luca-spec-check/SKILL.md +86 -0
  46. package/skills/luca-spec-check/scripts/spec-bundle.ts +194 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.27
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.26
8
+
9
+ ### Patch Changes
10
+
11
+ - 075d3a4: Spec: /luca-spec-check, a cold read of a spec by three fresh readers before ready-for-agent ([#678](https://github.com/asibilia/luca-framework/issues/678))
12
+
3
13
  ## 14.0.0-alpha.25
4
14
 
5
15
  No changes in this release.
package/README.md CHANGED
@@ -35,7 +35,7 @@ This is Luca v14, in alpha. Old Luca (v13) is still on npm; see [Coming from v13
35
35
  3. Check what `luca setup` wrote to `.luca/config.json`. It names your repo's GitHub name (like `tmnb`) as its memory vault in `muninn.vault`; change it if you want another.
36
36
 
37
37
  4. In Claude Code, in your repo, run `/setup-matt-pocock-skills` so the planning skills know your repo.
38
- 5. Write a spec with `/to-spec`, then split it into tickets with `/to-tickets`.
38
+ 5. Write a spec with `/to-spec`, then split it into tickets with `/to-tickets`. Before you add `ready-for-agent`, type `/luca-spec-check <spec number>` in Claude Code: three fresh readers give the spec a cold read and list what an agent would have to guess.
39
39
  6. In a Paseo chat, type `/luca-run <spec number>`. The board shows the run in the side panel, and the run ends in one pull request.
40
40
 
41
41
  If a ticket gets stuck, Luca comments on the spec and waits for your one-word reply. Type `/luca-unstick` in Claude Code or a Paseo chat, in the repo: it reads why, says what to do, and posts the reply once you say yes.
@@ -46,7 +46,7 @@ After a run, type `/luca-retro` the same way to look back on it: it reads the ru
46
46
 
47
47
  | Command | What it does |
48
48
  | ------- | ------------ |
49
- | `luca init` | Sets up this computer, once (safe to run again): MuninnDB and its Claude Code entry, the board in Paseo with its engine and Bun paths, the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`), and Luca's own `/luca-unstick` and `/luca-retro` skills. It asks before turning on Paseo's plugins, and leaves skills you already have alone (it only replaces its own two). `--skip-skills` skips the planning skills. Inside a git repo, it then runs `luca setup` for that repo. |
49
+ | `luca init` | Sets up this computer, once (safe to run again): MuninnDB and its Claude Code entry, the board in Paseo with its engine and Bun paths, the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`), and Luca's own `/luca-unstick`, `/luca-retro` and `/luca-spec-check` skills. It asks before turning on Paseo's plugins, and leaves skills you already have alone (it only replaces its own). `--skip-skills` skips the planning skills. Inside a git repo, it then runs `luca setup` for that repo. |
50
50
  | `luca setup` | Gets the repo in this folder ready: the labels a run needs (and the `release:*` labels when the repo uses changesets), `.luca/config.json` (a new one gets the repo's GitHub name as its vault; an existing vault is kept), and a check of GitHub sub-issues and dependencies. `--base <branch>` names the base branch. |
51
51
  | `luca doctor` | Checks this computer, this repo when you're in one, and what v13 left behind. Each problem comes with its exact fix. `luca doctor --fix` fixes what's safe without asking: it never deletes (v13 files go to a dated backup folder) and never commits, and it lists the repo files to commit. |
52
52
  | `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. A run that crashed, with no engine running it, doesn't stop it: upgrade says how to resume it on the new version. Then the version it just installed finishes the job with its own code: it reloads the board, copies its skills (every one it ships, new ones included), and says when to run `/reload-skills`. |
@@ -92,7 +92,7 @@ This is the one package that goes to npm. It's TypeScript source with no build a
92
92
  | ------ | ---------- |
93
93
  | `engine/` | The engine's source, from [`packages/engine/src`](../engine/src). Both bins point into it. |
94
94
  | `board/` | The board plugin's folder, from [`packages/board`](../board): `paseo-plugin.json`, its entry points, its client, server, and shared code, and its own `package.json`, so Paseo can install it as a folder source. |
95
- | `skills/` | Luca's own Claude Code skills, `luca-unstick` and `luca-retro`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy every folder in it with a `SKILL.md` to `~/.claude/skills`. |
95
+ | `skills/` | Luca's own Claude Code skills, `luca-unstick`, `luca-retro` and `luca-spec-check`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy every folder in it with a `SKILL.md` to `~/.claude/skills`. |
96
96
  | `LICENSE` | The repo's license. |
97
97
 
98
98
  Test files and test helpers stay out. The engine and the board stay private workspace packages, and this package lists every package they import at runtime as its own dependency.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.25",
3
+ "version": "14.0.0-alpha.27",
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.25"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.27"
@@ -103,7 +103,9 @@ export type LauncherFailure = z.infer<typeof LauncherFailureSchema>
103
103
  * of the model session, for the journal. `session_gone` marks a follow-up
104
104
  * that found its session closed or unknown (#521): no agent ran, and the
105
105
  * engine hands the follow-up's message to a fresh agent. `out_of_turns`
106
- * marks a turn that reached its role's turn limit (#543).
106
+ * marks a turn that reached its role's turn limit (#543). `session_closed`
107
+ * marks a turn interrupted for running past its time limit (#685): the
108
+ * launcher closed its session, so no follow-up can reach it.
107
109
  */
108
110
  export type AgentTurn =
109
111
  | {
@@ -120,6 +122,7 @@ export type AgentTurn =
120
122
  session?: AgentSession
121
123
  session_gone?: boolean
122
124
  out_of_turns?: boolean
125
+ session_closed?: boolean
123
126
  }
124
127
 
125
128
  /**
@@ -178,6 +181,12 @@ export type AgentLauncher = {
178
181
  * session's follow-ups run at it too.
179
182
  */
180
183
  effort: EffortLevel
184
+ /**
185
+ * The turn limit for this launch (#684), in place of its role's: a
186
+ * lens whose last try ran out of turns gets twice its role's. Left
187
+ * out, the role's limit.
188
+ */
189
+ max_turns?: number
181
190
  /**
182
191
  * Hears the turn's live progress (#628) each time it changes, before
183
192
  * the turn ends, for the engine's live file. Left out, nothing hears it.
@@ -105,6 +105,8 @@ export type Failed = {
105
105
  error: string
106
106
  /** The turn reached its role's turn limit (#543). */
107
107
  out_of_turns?: boolean
108
+ /** The turn ran past its time limit, and its session was closed (#685). */
109
+ session_closed?: boolean
108
110
  }
109
111
 
110
112
  export type Outcome = { ok: true; structured_output: unknown } | Failed
@@ -16,6 +16,7 @@ import {
16
16
  TicketReviewResultSchema,
17
17
  type AgentRole,
18
18
  } from './role-results'
19
+ import { MAX_TURNS } from './role-turns'
19
20
 
20
21
  import {
21
22
  DEFAULT_MODEL,
@@ -27,29 +28,14 @@ import { HELPER_TYPE, SEARCHER } from '../guards/helper-rules'
27
28
  import { guardRoleOf, permissionRules } from '../guards/role-rules'
28
29
  import { sandboxSettings } from '../guards/sandbox-settings'
29
30
 
31
+ export { MAX_TURNS } from './role-turns'
32
+
30
33
  /** The models an agent may launch on, by short name (`ModelNameSchema`). */
31
34
  export const MODEL_NAMES = ModelNameSchema.options
32
35
 
33
36
  /** The model reviewers run on, and every agent the decision model doesn't route (#603). */
34
37
  export const CLAUDE_MODEL = DEFAULT_MODEL
35
38
 
36
- /**
37
- * How many model turns one agent gets before its try ends. The integration
38
- * lens reads how every ticket fits together on the whole branch, so it gets
39
- * more than the other lenses.
40
- */
41
- export const MAX_TURNS: Record<AgentRole, number> = {
42
- 'test-writer': 80,
43
- implementer: 150,
44
- 'ticket-reviewer': 60,
45
- 'architecture-lens': 60,
46
- 'simplification-lens': 60,
47
- 'security-lens': 60,
48
- 'integration-lens': 100,
49
- 'rules-lens': 60,
50
- learner: 40,
51
- }
52
-
53
39
  /**
54
40
  * Why a model may not launch an agent, or `null` if it may: exactly one of
55
41
  * `MODEL_NAMES`. Every other short name is refused, since some (such as
@@ -181,6 +167,7 @@ export const agentOptions = ({
181
167
  cwd,
182
168
  model,
183
169
  effort,
170
+ max_turns,
184
171
  claude_path,
185
172
  config,
186
173
  common_git_dir,
@@ -199,6 +186,8 @@ export const agentOptions = ({
199
186
  cwd: string
200
187
  model: string
201
188
  effort: EffortLevel
189
+ /** The launch's own turn limit (#684), in place of the role's; left out, the role's. */
190
+ max_turns?: number
202
191
  /** The real path of the installed `claude` binary. */
203
192
  claude_path: string
204
193
  config: EngineConfig
@@ -264,7 +253,7 @@ export const agentOptions = ({
264
253
  // Streamed chunks only feed the live file's heartbeat (#631); the
265
254
  // launcher keeps nothing of them.
266
255
  includePartialMessages: true,
267
- maxTurns: MAX_TURNS[role],
256
+ maxTurns: max_turns ?? MAX_TURNS[role],
268
257
  hooks: {
269
258
  PreToolUse: [{ hooks: [guard_hook] }],
270
259
  ...(delivery_hook === null
@@ -80,6 +80,7 @@ export const createLaunch = ({
80
80
  messaging,
81
81
  model,
82
82
  effort,
83
+ max_turns,
83
84
  on_progress,
84
85
  }) => {
85
86
  const refused = checkLaunchModel({ model })
@@ -117,6 +118,7 @@ export const createLaunch = ({
117
118
  model,
118
119
  // Kept in the session's options, so its follow-ups run at it too.
119
120
  effort,
121
+ max_turns,
120
122
  claude_path: claudePath,
121
123
  config,
122
124
  common_git_dir: gitDir,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The texts of a refactor test update (#686): the section a fresh
3
+ * test-writer gets in place of the rejoin's test update section, the
4
+ * follow-up the implementer gets once it answered, and what the
5
+ * ticket-reviewer is told once it closed.
6
+ */
7
+
8
+ /**
9
+ * The section a test-writer gets when a refactor ticket's implementer sent
10
+ * a test back as bad: why, and that it may change only that file.
11
+ *
12
+ * @example
13
+ * refactorUpdateSection({ file: 'src/rows.test.ts', reason: 'It pins the old order.' })
14
+ */
15
+ export const refactorUpdateSection = ({
16
+ file,
17
+ reason,
18
+ }: {
19
+ file: string
20
+ reason: string
21
+ }): string =>
22
+ [
23
+ "## A refactor ticket's test was sent back as bad",
24
+ `The implementer says this test is wrong:\n\n- File: ${file}\n- Reason: ${reason || 'no reason given'}`,
25
+ `This is a refactor ticket. Change only ${file}, and only so it checks what the ticket's criteria say.`,
26
+ "The implementer's work is already in the worktree, so the test may pass now. That is fine here.",
27
+ ].join('\n\n')
28
+
29
+ /**
30
+ * The follow-up a refactor ticket's implementer gets once the test-writer
31
+ * fixed the test it sent back.
32
+ *
33
+ * @example
34
+ * refactorTestFixedMessage({ file: 'src/rows.test.ts' })
35
+ */
36
+ export const refactorTestFixedMessage = ({ file }: { file: string }): string =>
37
+ `The test-writer changed ${file} so it checks what the ticket's criteria say. Carry on with the refactor. You may follow renames into test files, but don't change what a test checks.`
38
+
39
+ /**
40
+ * The section a refactor ticket's reviewer gets once a refactor test update
41
+ * fixed a test: which file, why, and that only that change to what a test
42
+ * checks is expected.
43
+ *
44
+ * @example
45
+ * refactorFixedReviewSection({ fixed: [{ file: 'src/rows.test.ts', reason: 'It pins the old order.' }] })
46
+ */
47
+ export const refactorFixedReviewSection = ({
48
+ fixed,
49
+ }: {
50
+ fixed: { file: string; reason: string }[]
51
+ }): string =>
52
+ [
53
+ '## Tests a test-writer changed on purpose',
54
+ "The implementer sent these tests back as bad, and a test-writer changed them so they check what the ticket's criteria say. The engine checked it changed no other test file.",
55
+ fixed
56
+ .map(
57
+ ({ file, reason }) =>
58
+ `- File: ${file}\n Reason: ${reason || 'no reason given'}`
59
+ )
60
+ .join('\n'),
61
+ "This change to what these tests check is expected: judge it against the ticket's criteria, not as a behavior change. Any other change to what a test checks is still a blocker.",
62
+ ].join('\n\n')
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pure: how many model turns each role gets, whatever launcher runs it. A
3
+ * launch's own `max_turns` (#684) replaces its role's.
4
+ */
5
+ import type { AgentRole } from './role-results'
6
+
7
+ /**
8
+ * How many model turns one agent gets before its try ends. The integration
9
+ * lens reads how every ticket fits together on the whole branch, and the
10
+ * rules lens reads every rule file against it (#684), so they get more than
11
+ * the other lenses.
12
+ */
13
+ export const MAX_TURNS: Record<AgentRole, number> = {
14
+ 'test-writer': 80,
15
+ implementer: 150,
16
+ 'ticket-reviewer': 60,
17
+ 'architecture-lens': 60,
18
+ 'simplification-lens': 60,
19
+ 'security-lens': 60,
20
+ 'integration-lens': 100,
21
+ 'rules-lens': 100,
22
+ learner: 40,
23
+ }
@@ -78,6 +78,8 @@ export type ScriptedCall = {
78
78
  * `null` for a session the launcher doesn't know.
79
79
  */
80
80
  effort: EffortLevel | null
81
+ /** The turn limit a launch asked for (#684); left out when it asked for none. */
82
+ max_turns?: number
81
83
  /** What each of the turn's tool calls handed over, in order. */
82
84
  delivered: string[]
83
85
  }
@@ -204,6 +206,7 @@ export const createScriptedLauncher = ({
204
206
  messaging,
205
207
  model,
206
208
  effort,
209
+ max_turns,
207
210
  }) => {
208
211
  const session_id = `scripted-${role}-${ticket}-${calls.length + 1}`
209
212
  sessions.set(session_id, { messaging, model, effort })
@@ -216,6 +219,7 @@ export const createScriptedLauncher = ({
216
219
  may_edit_tests,
217
220
  model,
218
221
  effort,
222
+ ...(max_turns === undefined ? {} : { max_turns }),
219
223
  delivered: [],
220
224
  }
221
225
  calls.push(call)
@@ -93,6 +93,7 @@ export const createSessionPool = ({
93
93
  ok: false,
94
94
  failure: 'agent',
95
95
  error: `No result within ${timeoutMs} ms; the agent was interrupted.`,
96
+ session_closed: true,
96
97
  }
97
98
  : raced
98
99
  if (raced === 'timeout') {
@@ -51,46 +51,84 @@ const JunitPathSchema = z.string().min(1).refine(isFileInsideRepo, {
51
51
  'must be a file inside the repo, relative to its root: not an absolute path, not one that leaves the repo with "..", and not ending in "/"',
52
52
  })
53
53
 
54
+ /** The text a slow command's `run_files` must hold, where the files go (#683). */
55
+ export const RUN_FILES_PLACEHOLDER = '{files}'
56
+
54
57
  /**
55
- * One entry of a `checks.test` list: a command, or a command and its kind.
56
- * A `junit` command names the JUnit files it writes in `junit_path` (#668),
57
- * one path or a list; no other kind may have one.
58
+ * One entry of a `checks.test` or `checks.slow_test` list: a command, or a
59
+ * command and its kind. A `junit` command names the JUnit files it writes in
60
+ * `junit_path` (#668), one path or a list; no other kind may have one. A slow
61
+ * entry may also name the test files it owns (`file_patterns`) and how to run
62
+ * some of them (`run_files`, #683); they come together, and a `checks.test`
63
+ * entry may have neither.
58
64
  */
59
- const TestCommandEntrySchema = z.union([
60
- z.string().min(1),
61
- z
62
- .object({
63
- run: z.string().min(1),
64
- results: TestResultsSchema.optional(),
65
- junit_path: z
66
- .union([JunitPathSchema, z.array(JunitPathSchema).min(1)])
67
- .optional(),
68
- })
69
- .refine(
70
- ({ results, junit_path }) =>
71
- results !== 'junit' || junit_path !== undefined,
72
- {
73
- message:
74
- 'a "junit" test command needs junit_path: the JUnit files it writes, relative to the repo root',
75
- path: ['junit_path'],
76
- }
77
- )
78
- .refine(
79
- ({ results, junit_path }) =>
80
- results === 'junit' || junit_path === undefined,
81
- {
82
- message:
83
- 'junit_path is only for a test command with "results": "junit"',
84
- path: ['junit_path'],
85
- }
86
- ),
87
- ])
65
+ const testCommandEntrySchema = ({ slow }: { slow: boolean }) =>
66
+ z.union([
67
+ z.string().min(1),
68
+ z
69
+ .object({
70
+ run: z.string().min(1),
71
+ results: TestResultsSchema.optional(),
72
+ junit_path: z
73
+ .union([JunitPathSchema, z.array(JunitPathSchema).min(1)])
74
+ .optional(),
75
+ file_patterns: z.array(z.string().min(1)).min(1).optional(),
76
+ run_files: z.string().min(1).optional(),
77
+ })
78
+ .refine(
79
+ ({ file_patterns, run_files }) =>
80
+ slow ||
81
+ (file_patterns === undefined && run_files === undefined),
82
+ {
83
+ message:
84
+ 'checks.test: file_patterns and run_files belong only on checks.slow_test entries.',
85
+ }
86
+ )
87
+ .refine(
88
+ ({ file_patterns, run_files }) =>
89
+ !slow ||
90
+ (file_patterns === undefined) === (run_files === undefined),
91
+ {
92
+ message:
93
+ 'checks.slow_test: file_patterns and run_files come together; this entry has only one of them.',
94
+ }
95
+ )
96
+ .refine(
97
+ ({ run_files }) =>
98
+ !slow ||
99
+ run_files === undefined ||
100
+ run_files.includes(RUN_FILES_PLACEHOLDER),
101
+ {
102
+ message: `checks.slow_test: run_files must contain ${RUN_FILES_PLACEHOLDER}.`,
103
+ path: ['run_files'],
104
+ }
105
+ )
106
+ .refine(
107
+ ({ results, junit_path }) =>
108
+ results !== 'junit' || junit_path !== undefined,
109
+ {
110
+ message:
111
+ 'a "junit" test command needs junit_path: the JUnit files it writes, relative to the repo root',
112
+ path: ['junit_path'],
113
+ }
114
+ )
115
+ .refine(
116
+ ({ results, junit_path }) =>
117
+ results === 'junit' || junit_path === undefined,
118
+ {
119
+ message:
120
+ 'junit_path is only for a test command with "results": "junit"',
121
+ path: ['junit_path'],
122
+ }
123
+ ),
124
+ ])
88
125
 
89
- /** A test command setting: one command, or a list of them (see `testCommands`). */
90
- const TestCommandsSchema = z.union([
91
- z.string().min(1),
92
- z.array(TestCommandEntrySchema),
93
- ])
126
+ /**
127
+ * A test command setting: one command, or a list of them (see
128
+ * `testCommands`). Only a slow one's entries may own files (#683).
129
+ */
130
+ const testCommandsSchema = ({ slow }: { slow: boolean }) =>
131
+ z.union([z.string().min(1), z.array(testCommandEntrySchema({ slow }))])
94
132
 
95
133
  /** The config's `muninn` block. */
96
134
  export const MuninnConfigSchema = z.object({
@@ -204,13 +242,15 @@ export type ModelsConfig = z.infer<typeof ModelsConfigSchema>
204
242
  export const EngineConfigSchema = z.object({
205
243
  checks: z
206
244
  .object({
207
- test: TestCommandsSchema.optional(),
245
+ test: testCommandsSchema({ slow: false }).optional(),
208
246
  /**
209
247
  * The slow tests (#563): the tests `test` leaves out, run only at
210
- * the final review. Same shape as `test`. Left out, nothing
248
+ * the final review. Same shape as `test`, and an entry may also
249
+ * own some test files and run them in a ticket's gates
250
+ * (`file_patterns`, `run_files`, #683). Left out, nothing
211
251
  * changes.
212
252
  */
213
- slow_test: TestCommandsSchema.optional(),
253
+ slow_test: testCommandsSchema({ slow: true }).optional(),
214
254
  types: z.string().min(1).optional(),
215
255
  lint: z.string().min(1).optional(),
216
256
  })
@@ -37,6 +37,7 @@ export {
37
37
  ModelsConfigSchema,
38
38
  MuninnConfigSchema,
39
39
  ROUTED_ROLES,
40
+ RUN_FILES_PLACEHOLDER,
40
41
  TestResultsSchema,
41
42
  } from './engine-config-schema'
42
43
  export type {
@@ -55,8 +56,10 @@ export {
55
56
  hasSlowTests,
56
57
  isBunTestCommand,
57
58
  junitCommands,
59
+ ownSlowCommands,
58
60
  slowTestCommands,
59
61
  testCommands,
62
+ type OwnSlowCommand,
60
63
  type TestCommand,
61
64
  } from './test-commands'
62
65
 
@@ -77,6 +77,41 @@ export const slowTestCommands = ({
77
77
  config: EngineConfig
78
78
  }): TestCommand[] => commandsOf(config.checks.slow_test)
79
79
 
80
+ /**
81
+ * A slow test command that runs some of its files (#683): the test files it
82
+ * owns, and the command with `{files}` where they go.
83
+ */
84
+ export type OwnSlowCommand = { file_patterns: string[]; run_files: string }
85
+
86
+ /**
87
+ * The slow test commands with `run_files` (#683), in config order: a
88
+ * ticket's gates run its own files that match `file_patterns` through them.
89
+ *
90
+ * @example
91
+ * ownSlowCommands({ config: { ...config, checks: { test: 'bun test src', slow_test: [{ run: 'bun run ui', file_patterns: ['ui/**'], run_files: 'bun run ui-files {files}' }] } } })
92
+ * // [{ file_patterns: ['ui/**'], run_files: 'bun run ui-files {files}' }]
93
+ */
94
+ export const ownSlowCommands = ({
95
+ config,
96
+ }: {
97
+ config: EngineConfig
98
+ }): OwnSlowCommand[] => {
99
+ const value = config.checks.slow_test
100
+ if (value === undefined || typeof value === 'string') return []
101
+ return value.flatMap((entry) =>
102
+ typeof entry === 'string' ||
103
+ entry.file_patterns === undefined ||
104
+ entry.run_files === undefined
105
+ ? []
106
+ : [
107
+ {
108
+ file_patterns: entry.file_patterns,
109
+ run_files: entry.run_files,
110
+ },
111
+ ]
112
+ )
113
+ }
114
+
80
115
  /** Whether the config has a slow test command (#563). */
81
116
  export const hasSlowTests = ({ config }: { config: EngineConfig }): boolean =>
82
117
  slowTestCommands({ config }).length > 0
@@ -66,7 +66,9 @@ const failTurn = ({
66
66
  * every launcher alike; what others changed in the shared `.git` meanwhile
67
67
  * is journaled as `shared_git_changed`. Then the result is judged by its
68
68
  * structured output. Each failed turn is journaled once as `agent_failed`
69
- * with how it failed; the decision step picks what happens next. A writer's
69
+ * with how it failed; the decision step picks what happens next. A turn the
70
+ * launcher interrupted for its time limit then journals its session closed
71
+ * (#685), since the launcher closed it. A writer's
70
72
  * result then gets its chores done (`doChores`, #618); one the engine could
71
73
  * not do fails the turn as a `chore` failure. A launcher stop journals
72
74
  * `run_stopped` and ends the run. A turn the plan cut off (a rejected limit,
@@ -177,6 +179,16 @@ export const runTurn = async ({
177
179
  session_gone,
178
180
  out_of_turns,
179
181
  })
182
+ // A timed-out turn's session was closed by the launcher (#685):
183
+ // journal the close, so the next try is a fresh agent.
184
+ if (!turn.ok && turn.session_closed === true && session_id !== null) {
185
+ context.journal.append({
186
+ kind: 'agent_session_closed',
187
+ ticket,
188
+ role,
189
+ content: { role, session_id },
190
+ })
191
+ }
180
192
  return null
181
193
  }
182
194
  if (violations.length > 0) {
@@ -137,6 +137,23 @@ export type BuildAction =
137
137
  from_sha: string
138
138
  base_sha: string
139
139
  }
140
+ /**
141
+ * A refactor ticket's first bad test (#686): keep the implementer's work,
142
+ * hash the worktree's test files, and journal it all as
143
+ * `refactor_test_update`, so a fresh test-writer fixes `file`.
144
+ */
145
+ | {
146
+ type: 'open_refactor_update'
147
+ ticket: number
148
+ file: string
149
+ reason: string
150
+ }
151
+ /**
152
+ * After the refactor test update's test-writer answered: find the test
153
+ * files it changed since the update, journaled as
154
+ * `refactor_test_update_checked`.
155
+ */
156
+ | { type: 'check_refactor_update'; ticket: number }
140
157
  /** Remove these git worktrees at the end of the run; branches stay. */
141
158
  | { type: 'remove_worktrees'; paths: string[] }
142
159
  /** No ticket can move, and not every ticket has joined. */
@@ -16,6 +16,10 @@ import {
16
16
  import { handoffSection, reviewFixSection, reviewSections } from './review-text'
17
17
  import { retrySection } from './stuck-text'
18
18
 
19
+ import {
20
+ refactorFixedReviewSection,
21
+ refactorUpdateSection,
22
+ } from '../agents/refactor-update-prompts'
19
23
  import {
20
24
  CHANGESETS_LENS_SECTION,
21
25
  CHANGESETS_SECTION,
@@ -95,9 +99,14 @@ const promptSections = ({
95
99
  progress: TicketProgress
96
100
  }): string[] => {
97
101
  const sections = buildPromptSections({ role, progress })
98
- const { retried, crashed_turn } = progress
102
+ const { retried, crashed_turn, refactor_update } = progress
99
103
  return [
100
104
  ...sections,
105
+ ...(role === 'test-writer' &&
106
+ refactor_update !== null &&
107
+ refactor_update.result === null
108
+ ? [refactorUpdateSection(refactor_update)]
109
+ : []),
101
110
  ...(retried === null ? [] : [retrySection({ retried })]),
102
111
  ...(crashed_turn === role ? [CRASH_SECTION] : []),
103
112
  ]
@@ -152,8 +161,9 @@ const testUpdateSections = ({
152
161
 
153
162
  /**
154
163
  * The sections a launch adds for the ticket review: the reviewer's diff and
155
- * gate results (a re-review's new changes and earlier findings), or a
156
- * review fixer's findings while a review fix round is open.
164
+ * gate results (a re-review's new changes and earlier findings), and the
165
+ * tests a refactor test update changed on purpose (#686); or a review
166
+ * fixer's findings while a review fix round is open.
157
167
  */
158
168
  const reviewPromptSections = ({
159
169
  role,
@@ -164,10 +174,16 @@ const reviewPromptSections = ({
164
174
  }): string[] => {
165
175
  const fix = progress.review_fix
166
176
  if (role === 'ticket-reviewer') {
167
- return reviewSections({
168
- progress,
169
- base_sha: progress.worktree?.base_sha ?? 'HEAD',
170
- })
177
+ const { refactor_fixed } = progress
178
+ return [
179
+ ...reviewSections({
180
+ progress,
181
+ base_sha: progress.worktree?.base_sha ?? 'HEAD',
182
+ }),
183
+ ...(refactor_fixed.length === 0
184
+ ? []
185
+ : [refactorFixedReviewSection({ fixed: refactor_fixed })]),
186
+ ]
171
187
  }
172
188
  if (fix === null || progress.commits.green === null) return []
173
189
  if (role === 'test-writer' && !fix.tests_answered) {
@@ -209,8 +225,12 @@ export const launch = ({
209
225
  spec: snapshot.spec,
210
226
  ticket,
211
227
  refactor: isRefactorTicket({ ticket }),
212
- // A test update names the bad test in its own section.
213
- bad_test: progress.test_update === null ? progress.bad_test : null,
228
+ // A test update, or a refactor one, names the bad test in its own
229
+ // section.
230
+ bad_test:
231
+ progress.test_update === null && progress.refactor_update === null
232
+ ? progress.bad_test
233
+ : null,
214
234
  bad_test_by: progress.bad_test_source ?? undefined,
215
235
  sections: [
216
236
  ...promptSections({ role, progress }),