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

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,11 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8a58510: Spec: Luca v14 fixes: no empty changesets, board version, init sets up the repo ([#480](https://github.com/asibilia/luca-framework/issues/480))
8
+
3
9
  ## 14.0.0-alpha.0
4
10
 
5
11
  ### Major Changes
package/README.md CHANGED
@@ -24,17 +24,15 @@ This is Luca v14, in alpha. Old Luca (v13) is still on npm; see [Coming from v13
24
24
 
25
25
  While v14 is in alpha, npm's `latest` is still v13, so install `@alecsibilia/luca@alpha` for now.
26
26
 
27
- 2. Set up this computer, once:
27
+ 2. Set up this computer and your repo, once, from inside your repo:
28
28
 
29
29
  ```bash
30
30
  luca init
31
31
  ```
32
32
 
33
- 3. Set up your repo, once, from inside it:
33
+ Run inside a git repo, `luca init` sets up this computer, then runs `luca setup` to set up that repo too. Run outside a repo, it sets up only this computer. For another repo later, run `luca setup` from inside it.
34
34
 
35
- ```bash
36
- luca setup
37
- ```
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.
38
36
 
39
37
  4. In Claude Code, in your repo, run `/setup-matt-pocock-skills` so the planning skills know your repo.
40
38
  5. Write a spec with `/to-spec`, then split it into tickets with `/to-tickets`.
@@ -44,8 +42,8 @@ This is Luca v14, in alpha. Old Luca (v13) is still on npm; see [Coming from v13
44
42
 
45
43
  | Command | What it does |
46
44
  | ------- | ------------ |
47
- | `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, and the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`). It asks before turning on Paseo's plugins, and leaves skills you already have alone. `--skip-skills` skips the skills. |
48
- | `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`, and a check of GitHub sub-issues and dependencies. `--base <branch>` names the base branch. |
45
+ | `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, and the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`). It asks before turning on Paseo's plugins, and leaves skills you already have alone. `--skip-skills` skips the skills. Inside a git repo, it then runs `luca setup` for that repo. |
46
+ | `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. |
49
47
  | `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. |
50
48
  | `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. Then it reloads the board and says when to run `/reload-skills`. |
51
49
  | `luca upgrade --to <version>` | Installs that exact version, an older one too, to go back when a version breaks. |
@@ -1,7 +1,5 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
1
+ import { existsSync, realpathSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
- import { dirname, join } from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
3
 
6
4
  import type {
7
5
  PluginHandlerContext,
@@ -11,6 +9,7 @@ import type {
11
9
  import { createBoardServer } from './server/board-server'
12
10
  import { ENGINE_CHECK_MS } from './server/engine-watch'
13
11
  import { listProcesses } from './server/list-processes'
12
+ import { LUCA_VERSION } from './server/luca-version'
14
13
  import { runCommand } from './server/run-command'
15
14
  import { defaultRunsDir } from './server/run-journals'
16
15
  import { defaultRegistryPath } from './server/run-registry'
@@ -37,29 +36,6 @@ type Paseo = PluginHandlerContext['paseo']
37
36
 
38
37
  const log = (message: string) => console.error(`[${PLUGIN_ID}] ${message}`)
39
38
 
40
- /**
41
- * The Luca version in the board folder's package.json, which the published
42
- * package stamps with its own version; `null` when it can't be read.
43
- */
44
- const loadedVersion = (): string | null => {
45
- try {
46
- const manifest: unknown = JSON.parse(
47
- readFileSync(
48
- join(dirname(fileURLToPath(import.meta.url)), 'package.json'),
49
- 'utf8'
50
- )
51
- )
52
- return typeof manifest === 'object' &&
53
- manifest !== null &&
54
- 'version' in manifest &&
55
- typeof manifest.version === 'string'
56
- ? manifest.version
57
- : null
58
- } catch {
59
- return null
60
- }
61
- }
62
-
63
39
  /**
64
40
  * The board plugin's daemon side: the engine settings (their usage lines
65
41
  * kept in a file for the engine), `run.start` (launch a
@@ -154,9 +130,8 @@ export default function contribute(server: PluginServerContext) {
154
130
  connect({ context })
155
131
  return board.readBoard(input)
156
132
  })
157
- // Read once, as loaded: after an upgrade it differs until a reload.
158
- const version = loadedVersion()
159
- server.handle(boardVersionRpc, () => ({ version }))
133
+ // Fixed as loaded: after an upgrade it differs until a reload.
134
+ server.handle(boardVersionRpc, () => ({ version: LUCA_VERSION }))
160
135
 
161
136
  // Lifecycle hooks time out at 30 s, so the first check is not awaited.
162
137
  void board.checkEngines()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.0",
3
+ "version": "14.0.0-alpha.1",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -0,0 +1,2 @@
1
+ /** The Luca version of this board, stamped when the package was packed. */
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.1"
@@ -49,6 +49,7 @@ const STUCK_REASONS: Record<string, string> = {
49
49
  join_failed: "The ticket couldn't join the run branch.",
50
50
  join_gates_failed: 'The checks failed after the ticket joined.',
51
51
  install_failed: 'Installing the dependencies failed.',
52
+ prepare_failed: "The repo's prepare command failed.",
52
53
  setup_change_needed:
53
54
  'An agent needs a test setup file changed; only you may change one.',
54
55
  crashed: 'The engine crashed in the same step again and again.',
@@ -117,8 +117,8 @@ export type BoardReadInput = z.input<typeof BoardReadInputSchema>
117
117
  export type BoardReadOutput = z.infer<typeof BoardReadOutputSchema>
118
118
 
119
119
  /**
120
- * API Response: the Luca version of the loaded board, read from its folder's
121
- * package.json when it was loaded; `null` when it has none.
120
+ * API Response: the Luca version of the loaded board, from its version
121
+ * module (the dev value from the repo's source); `null` from older boards.
122
122
  */
123
123
  export const BoardVersionOutputSchema = z.object({
124
124
  version: z.string().nullable(),
@@ -109,6 +109,23 @@ export const runNotesSection = ({
109
109
  .join('\n'),
110
110
  ]
111
111
 
112
+ /**
113
+ * The section a test-writer or implementer gets when the repo has a prepare
114
+ * command: the engine runs it before its test runs, so tests that need its
115
+ * outputs may fail when the agent runs them itself.
116
+ *
117
+ * @example
118
+ * prepareSection({ command: 'bun run build:rom' })
119
+ * // '## This repo has a prepare command\n\n...'
120
+ */
121
+ export const prepareSection = ({ command }: { command: string }): string =>
122
+ [
123
+ '## This repo has a prepare command',
124
+ `The engine runs \`${command}\` in the worktree before each of its test runs (the red check and the gates), so tests that need its outputs find them up to date. ` +
125
+ "Never run it yourself. Tests that need its outputs may fail when you run them, and still pass at the engine's checks. " +
126
+ "If the engine's prepare command fails after your turn, its output comes back to you like a failed check.",
127
+ ].join('\n\n')
128
+
112
129
  /** What each lens judges the whole run branch on, in one paragraph. */
113
130
  const LENS_TASK_TEXT =
114
131
  'You are one lens of the final review: a fresh, read-only reviewer of the WHOLE run branch, every ticket of the spec together. Write nothing.'
@@ -24,25 +24,13 @@ import {
24
24
  paseoOf,
25
25
  } from './computer-adapters-real'
26
26
  import { runDoctor } from './doctor'
27
- import { githubOf, memoryOf } from './repo-adapters-real'
27
+ import { githubOf, memoryOf, repoTop } from './repo-adapters-real'
28
28
 
29
29
  import type { MemoryClient } from '../memory/memory-client'
30
- import { runCommand } from '../shell/run-command'
31
30
 
32
31
  /** `luca doctor`'s usage line. */
33
32
  export const DOCTOR_USAGE = 'Usage: luca doctor [--fix]'
34
33
 
35
- /** The top of the git repo `cwd` is in, or `null` outside one. */
36
- const repoTop = async ({ cwd }: { cwd: string }): Promise<string | null> => {
37
- const top = await runCommand({
38
- cmd: ['git', 'rev-parse', '--show-toplevel'],
39
- cwd,
40
- })
41
- return top.exit_code === 0 && top.stdout.trim() !== ''
42
- ? top.stdout.trim()
43
- : null
44
- }
45
-
46
34
  /** Runs `luca doctor` with the flags after `doctor`; returns the exit code. */
47
35
  export const doctorCommand = async ({
48
36
  argv,
@@ -9,7 +9,8 @@
9
9
  * all of that: runs then have memory off. It puts the board into Paseo from
10
10
  * Luca's own install folder and writes its engine and Bun paths, asking
11
11
  * before it turns Paseo's plugins on. It installs the planning skills that
12
- * aren't there yet; `--skip-skills` skips them. See `runInit`.
12
+ * aren't there yet; `--skip-skills` skips them. Inside a git repo, it then
13
+ * runs `luca setup` for that repo. See `runInit`.
13
14
  *
14
15
  * Exits 0 when done, 1 when a step failed, 2 on bad flags.
15
16
  */
@@ -29,7 +30,9 @@ import {
29
30
  } from './computer-adapters-real'
30
31
  import { reason } from './doctor-checks'
31
32
  import { runInit, type Launchctl, type SkillsTool } from './init'
33
+ import { githubOf, memoryOf, repoTop } from './repo-adapters-real'
32
34
 
35
+ import type { MemoryClient } from '../memory/memory-client'
33
36
  import { runCommand } from '../shell/run-command'
34
37
 
35
38
  /** `luca init`'s flags. */
@@ -103,6 +106,24 @@ const skillsOf = ({ home }: { home: string }): SkillsTool => ({
103
106
  },
104
107
  })
105
108
 
109
+ /**
110
+ * MuninnDB over MCP, found on first use: init writes Claude Code's `muninn`
111
+ * entry before its repo part reads it.
112
+ */
113
+ const lazyMemory = (): MemoryClient => {
114
+ let client: Promise<MemoryClient> | null = null
115
+ const found = () => (client ??= memoryOf())
116
+ return {
117
+ recall: async (args) => (await found()).recall(args),
118
+ remember: async (args) => (await found()).remember(args),
119
+ evolve: async (args) => (await found()).evolve(args),
120
+ feedback: async (args) => (await found()).feedback(args),
121
+ close: async () => {
122
+ if (client !== null) await (await client).close()
123
+ },
124
+ }
125
+ }
126
+
106
127
  /** Runs `luca init` with the flags after `init`; returns the exit code. */
107
128
  export const initCommand = async ({
108
129
  argv,
@@ -115,11 +136,21 @@ export const initCommand = async ({
115
136
  }
116
137
  const home = homedir()
117
138
  const paseo = paseoOf({ client_id: 'luca-init' })
139
+ const memory = lazyMemory()
118
140
  try {
141
+ const top = await repoTop({ cwd: process.cwd() })
119
142
  const end = await runInit({
120
143
  home,
121
144
  skip_muninndb: argv.includes('--skip-muninndb'),
122
145
  skip_skills: argv.includes('--skip-skills'),
146
+ repo:
147
+ top === null
148
+ ? null
149
+ : {
150
+ path: top,
151
+ github: await githubOf({ cwd: top }),
152
+ memory,
153
+ },
123
154
  muninn: muninnOf({ home }),
124
155
  muninn_health: muninnHealth,
125
156
  claude: claudeOf({ home }),
@@ -139,5 +170,6 @@ export const initCommand = async ({
139
170
  return 1
140
171
  } finally {
141
172
  await paseo.close()
173
+ await memory.close().catch(() => undefined)
142
174
  }
143
175
  }
@@ -12,6 +12,7 @@ import type {
12
12
  Paseo,
13
13
  } from './computer-adapters'
14
14
  import { computerChecks } from './computer-checks'
15
+ import type { DoctorRepo } from './doctor'
15
16
  import {
16
17
  formatChecks,
17
18
  hasProblem,
@@ -19,11 +20,12 @@ import {
19
20
  type DoctorCheck,
20
21
  } from './doctor-checks'
21
22
  import { ensureMuninnEntry, hideToken } from './muninn-entry'
23
+ import { runSetup } from './setup'
22
24
 
23
25
  /**
24
26
  * `luca init`: sets up this computer for Luca, once, and is safe to run
25
27
  * again. It has three parts, each run even when an earlier one failed, and
26
- * ends with doctor's computer checks.
28
+ * ends with doctor's computer checks, then sets up the repo it runs in.
27
29
  *
28
30
  * Memory:
29
31
  *
@@ -48,6 +50,10 @@ import { ensureMuninnEntry, hideToken } from './muninn-entry'
48
50
  * `mattpocock/skills` with the `skills` tool, skipping any already there.
49
51
  * `skip_skills` skips this part.
50
52
  *
53
+ * The repo: inside a git repo, after the computer's checks, it runs `luca
54
+ * setup` for that repo (see `runSetup`), with the output setup gives on its
55
+ * own. Outside one, it stops after the computer part.
56
+ *
51
57
  * MuninnDB's CLI, `claude mcp`, `launchctl`, Paseo, the question to the
52
58
  * user, the `skills` tool, and the computer's tools are adapters, so tests
53
59
  * use fakes. The token is never printed, returned, or thrown.
@@ -258,17 +264,19 @@ const setUpMemory = async ({
258
264
 
259
265
  /**
260
266
  * Runs `luca init` against `home` with these adapters: memory, then the
261
- * board, then the planning skills, then doctor's computer checks, printed
262
- * last. Never throws: a failure is logged (token hidden) and ends with
263
- * `ok: false`, as does a check that is a problem.
267
+ * board, then the planning skills, then doctor's computer checks, then
268
+ * `luca setup` in `repo` when there is one. Never throws: a failure is
269
+ * logged (token hidden) and ends with `ok: false`, as does a check that is
270
+ * a problem or a setup to-do.
264
271
  *
265
272
  * @example
266
- * const end = await runInit({ home: homedir(), skip_muninndb: false, skip_skills: false, muninn, muninn_health, claude, launchctl, paseo, skills, ask, computer, ...(await lucaInstall()), log: console.log })
273
+ * const end = await runInit({ home: homedir(), skip_muninndb: false, skip_skills: false, repo: null, muninn, muninn_health, claude, launchctl, paseo, skills, ask, computer, ...(await lucaInstall()), log: console.log })
267
274
  */
268
275
  export const runInit = async ({
269
276
  home,
270
277
  skip_muninndb,
271
278
  skip_skills,
279
+ repo = null,
272
280
  muninn,
273
281
  muninn_health,
274
282
  claude,
@@ -286,6 +294,8 @@ export const runInit = async ({
286
294
  home: string
287
295
  skip_muninndb: boolean
288
296
  skip_skills: boolean
297
+ /** The git repo init runs in, set up after the computer; `null` outside one. */
298
+ repo?: DoctorRepo | null
289
299
  muninn: MuninnCli
290
300
  muninn_health: MuninnHealth
291
301
  claude: ClaudeMcp
@@ -333,12 +343,23 @@ export const runInit = async ({
333
343
  paseo,
334
344
  })
335
345
  for (const line of formatChecks({ checks: doctor })) log(line)
346
+ const setup =
347
+ repo === null
348
+ ? null
349
+ : await runSetup({
350
+ repo: repo.path,
351
+ github: repo.github,
352
+ memory: repo.memory,
353
+ base_branch: repo.base_branch,
354
+ log,
355
+ })
336
356
  return {
337
357
  ok:
338
358
  memory.ok &&
339
359
  board.ok &&
340
360
  planning.ok &&
341
- !hasProblem({ checks: doctor }),
361
+ !hasProblem({ checks: doctor }) &&
362
+ (setup?.ok ?? true),
342
363
  memory: memory.memory,
343
364
  message: planning.message,
344
365
  doctor,
@@ -3,7 +3,8 @@
3
3
  * `luca`: the command people run, with subcommands:
4
4
  *
5
5
  * luca init [--skip-muninndb] [--skip-skills]
6
- * sets up this computer, once
6
+ * sets up this computer, once, then
7
+ * the repo in this folder
7
8
  * luca setup [--base <branch>] gets the repo in this folder ready
8
9
  * luca upgrade [--to <version>] moves to another version of Luca
9
10
  * luca doctor [--fix] checks this computer and repo; --fix
@@ -22,7 +23,7 @@ const USAGE = `Usage: luca <command>
22
23
 
23
24
  Commands:
24
25
  init [--skip-muninndb] [--skip-skills]
25
- Set up this computer for Luca (once)
26
+ Set up this computer for Luca (once), then the repo you're in
26
27
  setup [--base <branch>] Get the repo in this folder ready for Luca
27
28
  upgrade [--to <version>] Move to the newest Luca on your channel, or to <version>
28
29
  doctor [--fix] Check this computer and repo; --fix fixes what's safe`
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The real repo adapters `luca setup` and `luca doctor` share: the repo's
3
- * GitHub side, and MuninnDB over MCP for its vault.
2
+ * The real repo adapters `luca setup`, `luca init`, and `luca doctor`
3
+ * share: the repo's top folder, its GitHub side, and MuninnDB over MCP for
4
+ * its vault.
4
5
  */
5
6
  import type { SetupGitHub } from './setup'
6
7
 
@@ -10,12 +11,28 @@ import {
10
11
  createMuninnMcpClient,
11
12
  muninnSettings,
12
13
  } from '../memory/muninn-mcp-client'
14
+ import { runCommand } from '../shell/run-command'
13
15
  import {
14
16
  createGitHubTracker,
15
17
  ghLogin,
16
18
  githubRepoOf,
17
19
  } from '../tracker/github-tracker'
18
20
 
21
+ /** The top of the git repo `cwd` is in, or `null` outside one. */
22
+ export const repoTop = async ({
23
+ cwd,
24
+ }: {
25
+ cwd: string
26
+ }): Promise<string | null> => {
27
+ const top = await runCommand({
28
+ cmd: ['git', 'rev-parse', '--show-toplevel'],
29
+ cwd,
30
+ })
31
+ return top.exit_code === 0 && top.stdout.trim() !== ''
32
+ ? top.stdout.trim()
33
+ : null
34
+ }
35
+
19
36
  /**
20
37
  * The real GitHub side: the GitHub tracker of the repo at `cwd` for its
21
38
  * labels and issue links, and `gh` for the login. With no GitHub repo, the
@@ -35,7 +35,8 @@ import {
35
35
  /**
36
36
  * `luca setup`: gets a repo ready for Luca. It creates the labels a run
37
37
  * needs (and the `release:*` ones in a repo with changesets), writes a
38
- * starting `.luca/config.json` (or converts old Luca's, or leaves a
38
+ * starting `.luca/config.json` with the repo's GitHub name as its vault
39
+ * (or converts old Luca's, or leaves a
39
40
  * new-style one alone), runs `luca doctor`'s repo checks once, and ends
40
41
  * with a plain list of what's done and what's left, then a pointer to
41
42
  * `/setup-matt-pocock-skills`. It never commits, and running it again
@@ -262,6 +263,18 @@ const oldVault = ({ raw }: { raw: Record<string, unknown> }): string | null =>
262
263
  vaultName(isRecord(raw.muninn) ? raw.muninn.vault : undefined) ??
263
264
  vaultName(raw.vault)
264
265
 
266
+ /**
267
+ * The `name` part of a GitHub `owner/name`, a new repo's vault. Pure.
268
+ *
269
+ * @example
270
+ * repoName({ github_repo: 'asibilia/tmnb' }) // 'tmnb'
271
+ */
272
+ const repoName = ({
273
+ github_repo,
274
+ }: {
275
+ github_repo: string | null
276
+ }): string | null => vaultName(github_repo?.split('/').at(-1)?.trim() ?? null)
277
+
265
278
  const writeConfig = async ({
266
279
  repo,
267
280
  config,
@@ -591,7 +604,7 @@ const list = (lines: string[]): string =>
591
604
  * `refactor`, and `needs-info` labels, and in a repo with
592
605
  * `.changeset/config.json` the `release:*` ones too; writes a starting
593
606
  * `.luca/config.json` from `package.json` when there is none (see
594
- * `guessChecks`), rewrites an old-Luca config into the new shape keeping
607
+ * `guessChecks`), with the repo's GitHub name as its `muninn.vault`, rewrites an old-Luca config into the new shape keeping
595
608
  * its `muninn.vault` (or an older top-level `vault`), and leaves a
596
609
  * new-style config alone; then checks the `gh` login and runs `luca
597
610
  * doctor`'s repo checks (`repoChecks`) once: the GitHub remote, sub-issues
@@ -640,7 +653,10 @@ export const runSetup = async ({
640
653
 
641
654
  const found = await findConfig({ repo })
642
655
  if (found.kind === 'missing' || found.kind === 'old') {
643
- const vault = found.kind === 'old' ? oldVault({ raw: found.raw }) : null
656
+ const vault =
657
+ found.kind === 'old'
658
+ ? oldVault({ raw: found.raw })
659
+ : repoName({ github_repo })
644
660
  await writeConfig({
645
661
  repo,
646
662
  config: startingConfig({
@@ -653,6 +669,11 @@ export const runSetup = async ({
653
669
  ? `Wrote ${ENGINE_CONFIG_FILE} from package.json's scripts. Check it, then merge it to main through a PR.`
654
670
  : `Rewrote old Luca's ${ENGINE_CONFIG_FILE} into the new shape${vault === null ? '' : `, keeping the \`${vault}\` vault`}. Check it, then merge it to main through a PR.`
655
671
  )
672
+ if (found.kind === 'missing' && vault !== null) {
673
+ done_lines.push(
674
+ `Set the memory vault to \`${vault}\`, the repo's GitHub name. Change \`muninn.vault\` in ${ENGINE_CONFIG_FILE} to use another.`
675
+ )
676
+ }
656
677
  } else if (found.kind === 'new') {
657
678
  done_lines.push(
658
679
  `${ENGINE_CONFIG_FILE} is new-style, so it was left as it is`
@@ -54,6 +54,12 @@ export const EngineConfigSchema = z.object({
54
54
  lint: z.string().min(1).optional(),
55
55
  })
56
56
  .default({}),
57
+ /**
58
+ * A command the engine runs in a checkout before every test run there
59
+ * (baseline, red check, gates), for repos whose tests need build outputs
60
+ * that are gitignored, such as `bun run build:rom`. Agents never run it.
61
+ */
62
+ prepare: z.string().min(1).optional(),
57
63
  test_file_patterns: z.array(z.string()).default(['**/*.test.ts']),
58
64
  test_setup_files: z.array(z.string()).default([]),
59
65
  rule_files: z.array(z.string()).default([]),
@@ -116,8 +116,8 @@ export const changedPackages = ({
116
116
 
117
117
  /**
118
118
  * A changeset's text: front matter naming each package with the bump, then
119
- * the summary. `release:none` (or no package) gives empty front matter, a
120
- * changeset that bumps nothing.
119
+ * the summary. `release:none` gets no changeset at all (#476), so it has no
120
+ * text.
121
121
  *
122
122
  * @example
123
123
  * changesetText({ packages: ['@acme/a'], bump: 'minor', summary: 'Add a' })
@@ -129,12 +129,11 @@ export const changesetText = ({
129
129
  summary,
130
130
  }: {
131
131
  packages: string[]
132
- bump: ChangesetBump
132
+ bump: Exclude<ChangesetBump, 'none'>
133
133
  summary: string
134
134
  }): string => {
135
- const releases =
136
- bump === 'none'
137
- ? []
138
- : packages.map((name) => `${JSON.stringify(name)}: ${bump}\n`)
135
+ const releases = packages.map(
136
+ (name) => `${JSON.stringify(name)}: ${bump}\n`
137
+ )
139
138
  return `---\n${releases.join('')}---\n\n${summary}\n`
140
139
  }
@@ -37,7 +37,11 @@ import {
37
37
  } from './review-text'
38
38
  import { retrySection, setupChangeDetail } from './stuck-text'
39
39
 
40
- import { rejoinSection, rolePrompt } from '../agents/role-prompts'
40
+ import {
41
+ prepareSection,
42
+ rejoinSection,
43
+ rolePrompt,
44
+ } from '../agents/role-prompts'
41
45
  import type { AgentRole, CriterionTests } from '../agents/role-results'
42
46
  import type { TicketSnapshot } from '../intake/intake-schemas'
43
47
  import type {
@@ -970,6 +974,14 @@ const nextTicketStep = ({
970
974
  detail: ticketFailure,
971
975
  })
972
976
  }
977
+ const { prepare } = progress
978
+ if (prepare !== null && !prepare.ok) {
979
+ return stuck({
980
+ ticket: number,
981
+ reason: 'prepare_failed',
982
+ detail: `\`${prepare.command}\` failed in the worktree of #${number} (exit ${prepare.exit_code ?? 'none'}) before any agent worked on it. Agents never run the prepare command, so fix what it needs (a missing tool, say) and retry.\n${prepare.output}`,
983
+ })
984
+ }
973
985
  if (progress.baseline === null) return baseline_step
974
986
  if (progress.commits.green === null) {
975
987
  const rejoin = rejoinStep({
@@ -1058,6 +1070,44 @@ export const decideBuild = ({
1058
1070
  }: {
1059
1071
  state: RunState
1060
1072
  spec_number: number
1073
+ }): BuildAction[] => {
1074
+ const steps = buildSteps({ state, spec_number })
1075
+ const command = state.config?.prepare
1076
+ return command === undefined
1077
+ ? steps
1078
+ : steps.map((step) => withPrepareSection({ step, command }))
1079
+ }
1080
+
1081
+ /**
1082
+ * A fresh test-writer's or implementer's launch (a ticket's or the final
1083
+ * review's fixers), with the repo's prepare command named in its prompt.
1084
+ * Any other step as it is.
1085
+ */
1086
+ const withPrepareSection = ({
1087
+ step,
1088
+ command,
1089
+ }: {
1090
+ step: BuildAction
1091
+ command: string
1092
+ }): BuildAction => {
1093
+ const writesCode =
1094
+ (step.type === 'launch_agent' || step.type === 'launch_final_fixer') &&
1095
+ (step.role === 'test-writer' || step.role === 'implementer')
1096
+ return writesCode
1097
+ ? {
1098
+ ...step,
1099
+ prompt: `${step.prompt}\n\n${prepareSection({ command })}`,
1100
+ }
1101
+ : step
1102
+ }
1103
+
1104
+ /** `decideBuild`'s steps, before the prepare command joins any prompt. */
1105
+ const buildSteps = ({
1106
+ state,
1107
+ spec_number,
1108
+ }: {
1109
+ state: RunState
1110
+ spec_number: number
1061
1111
  }): BuildAction[] => {
1062
1112
  const { snapshot, run_branch, run_branch_install, pull_request } = state
1063
1113
  const base_branch = state.base_branch ?? 'main'
@@ -17,7 +17,7 @@ import {
17
17
  type RoleResult,
18
18
  } from '../agents/role-results'
19
19
  import { bunTestCommands, type EngineConfig } from '../config/engine-config'
20
- import { runGates, shellCheck } from '../gates/gate-runner'
20
+ import { prepareCheck, runGates, shellCheck } from '../gates/gate-runner'
21
21
  import { newCodeFiles, importStem, scanLeftovers } from '../gates/leftover-scan'
22
22
  import {
23
23
  dependenciesChanged,
@@ -155,6 +155,7 @@ const runRedCheck = async ({
155
155
  what: `baseline test run for #${action.ticket}`,
156
156
  })
157
157
  const test_files = await testFilesIn({ context, cwd: path })
158
+ const prepare = await prepareCheck({ config: context.config, cwd: path })
158
159
  const current = await runBunTests({
159
160
  cwd: path,
160
161
  commands: bunTestCommands(context),
@@ -170,7 +171,7 @@ const runRedCheck = async ({
170
171
  )
171
172
  const sources: Record<string, string | null> = {}
172
173
  for (const file of files) sources[file] = await readOrNull(join(path, file))
173
- const result = checkRed({
174
+ const checked = checkRed({
174
175
  criteria_ids: action.criteria_ids,
175
176
  mapping: action.mapping,
176
177
  baseline,
@@ -178,6 +179,19 @@ const runRedCheck = async ({
178
179
  test_files,
179
180
  sources,
180
181
  })
182
+ // A failed prepare after the test-writer's turn is one more problem,
183
+ // for the test-writer's fix loop.
184
+ const result =
185
+ prepare === null || prepare.ok
186
+ ? checked
187
+ : {
188
+ ...checked,
189
+ ok: false,
190
+ problems: [
191
+ `The prepare command \`${prepare.command}\` failed (exit ${prepare.exit_code ?? 'none'}):\n${prepare.output}`,
192
+ ...checked.problems,
193
+ ],
194
+ }
181
195
  context.journal.append({
182
196
  kind: 'red_check',
183
197
  ticket: action.ticket,
@@ -935,6 +949,21 @@ export const executeBuildAction = async ({
935
949
  }
936
950
  case 'run_baseline_tests': {
937
951
  const { path } = ticketWorktree({ state, ticket: action.ticket })
952
+ const prepare = await prepareCheck({
953
+ config: context.config,
954
+ cwd: path,
955
+ })
956
+ if (prepare !== null) {
957
+ journal.append({
958
+ kind: 'baseline_prepared',
959
+ ticket: action.ticket,
960
+ role: null,
961
+ content: { check: prepare },
962
+ })
963
+ // No agent has worked yet, so a failed prepare leaves the
964
+ // tests unrun and the ticket stuck on its output.
965
+ if (!prepare.ok) return
966
+ }
938
967
  const run = await runBunTests({
939
968
  cwd: path,
940
969
  commands: bunTestCommands(context),
@@ -86,9 +86,9 @@ export const workspacePackages = async ({
86
86
  * Writes the run's changeset on the run branch, commits it, and pushes the
87
87
  * run branch, then journals `changeset_written`. It names every workspace
88
88
  * package with files changed since the run branch's base, minus the ones
89
- * the config ignores; `release:none` names none. A redo after a crash finds
90
- * its own file already committed and commits nothing twice. A repo whose
91
- * config is gone by now gets no changeset.
89
+ * the config ignores. A redo after a crash finds its own file already
90
+ * committed and commits nothing twice. A `release:none` spec (#476), or a
91
+ * repo whose config is gone by now, gets no changeset.
92
92
  */
93
93
  export const writeChangeset = async ({
94
94
  context,
@@ -118,21 +118,29 @@ export const writeChangeset = async ({
118
118
  })
119
119
  return
120
120
  }
121
+ if (bump === 'none') {
122
+ await git.push({ cwd, branch })
123
+ journal.append({
124
+ kind: 'changeset_written',
125
+ ticket: null,
126
+ role: null,
127
+ content: {
128
+ path: null,
129
+ bump,
130
+ packages: [],
131
+ sha: await git.head({ cwd }),
132
+ },
133
+ })
134
+ return
135
+ }
121
136
  const { ignore } = ChangesetConfigSchema.parse(
122
137
  await readJson(join(cwd, CHANGESET_CONFIG))
123
138
  )
124
- const packages =
125
- bump === 'none'
126
- ? []
127
- : changedPackages({
128
- packages: await workspacePackages({ cwd }),
129
- files: await git.filesBetween({
130
- cwd,
131
- from: base_sha,
132
- to: 'HEAD',
133
- }),
134
- ignore,
135
- })
139
+ const packages = changedPackages({
140
+ packages: await workspacePackages({ cwd }),
141
+ files: await git.filesBetween({ cwd, from: base_sha, to: 'HEAD' }),
142
+ ignore,
143
+ })
136
144
  const path = changesetPath({ spec_number, run_id: basename(run_dir) })
137
145
  await Bun.write(join(cwd, path), changesetText({ packages, bump, summary }))
138
146
  const changes = await git.changes({ cwd })
@@ -30,6 +30,8 @@ const REASON_LINES: Record<StuckReason, string> = {
30
30
  join_gates_failed:
31
31
  'The checks still fail once the ticket joins the run branch.',
32
32
  install_failed: 'Installing the dependencies failed.',
33
+ prepare_failed:
34
+ "The repo's prepare command failed before any agent worked.",
33
35
  setup_change_needed:
34
36
  'An agent needs a test setup file changed, and only you may change one.',
35
37
  crashed: 'The engine crashed in the same step, again and again.',
@@ -65,6 +67,8 @@ const suggestion = ({
65
67
  return `Its join was undone, so the run branch is safe. Reply ${retry} to join again and fix it on top of the run branch, or \`skip #${n}\`.`
66
68
  case 'install_failed':
67
69
  return `Fix the manifest or lockfile on the base branch, then reply ${retry}.`
70
+ case 'prepare_failed':
71
+ return `Fix what the prepare command needs on this computer (a missing tool, say), or the command in \`.luca/config.json\`, then reply ${retry}.`
68
72
  case 'setup_change_needed':
69
73
  return `Make that change yourself in the worktree, then reply ${retry}.`
70
74
  case 'crashed':
@@ -33,6 +33,26 @@ export const shellCheck = async ({
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Runs the config's prepare command in `cwd`, as the `prepare` check, or
38
+ * `null` when the config has none. The engine runs it before every test run
39
+ * in a checkout, so tests that need build outputs find them up to date.
40
+ *
41
+ * @example
42
+ * const prepare = await prepareCheck({ config, cwd })
43
+ * if (prepare !== null && !prepare.ok) console.log(prepare.output)
44
+ */
45
+ export const prepareCheck = async ({
46
+ config,
47
+ cwd,
48
+ }: {
49
+ config: EngineConfig
50
+ cwd: string
51
+ }): Promise<GateCheck | null> =>
52
+ config.prepare === undefined
53
+ ? null
54
+ : shellCheck({ name: 'prepare', command: config.prepare, cwd })
55
+
36
56
  /**
37
57
  * Runs every gate the engine config names, in order: each test command,
38
58
  * types, lint. All must pass. Unset gates are left out; intake refuses a
@@ -41,7 +61,8 @@ export const shellCheck = async ({
41
61
  *
42
62
  * With an `install` command (see `installCommand`), the engine runs it first,
43
63
  * as the `install` check. A failed install stops there: the other gates
44
- * would only fail on the missing packages.
64
+ * would only fail on the missing packages. The config's prepare command
65
+ * runs next, as the `prepare` check, and a failed one stops there too.
45
66
  *
46
67
  * @example
47
68
  * const { ok, checks } = await runGates({ cwd, config, test_files, report_file, install: null })
@@ -69,6 +90,11 @@ export const runGates = async ({
69
90
  checks.push(installed)
70
91
  if (!installed.ok) return { ok: false, checks }
71
92
  }
93
+ const prepared = await prepareCheck({ config, cwd })
94
+ if (prepared !== null) {
95
+ checks.push(prepared)
96
+ if (!prepared.ok) return { ok: false, checks }
97
+ }
72
98
  const { types, lint } = config.checks
73
99
  for (const [index, { run: command, results }] of testCommands({
74
100
  config,
@@ -28,9 +28,16 @@ export type TestRun = z.infer<typeof TestRunSchema>
28
28
 
29
29
  /**
30
30
  * The gates the engine config can name, plus `install`: the engine's own
31
- * install, run first when a package manifest changed.
31
+ * install, run first when a package manifest changed, and `prepare`: the
32
+ * config's prepare command, run before the tests.
32
33
  */
33
- export const GateNameSchema = z.enum(['install', 'test', 'types', 'lint'])
34
+ export const GateNameSchema = z.enum([
35
+ 'install',
36
+ 'prepare',
37
+ 'test',
38
+ 'types',
39
+ 'lint',
40
+ ])
34
41
 
35
42
  export type GateName = z.infer<typeof GateNameSchema>
36
43
 
@@ -151,6 +151,17 @@ const TicketWorktreeCreatedEntrySchema = z.object({
151
151
  content: WorktreeSchema,
152
152
  })
153
153
 
154
+ /**
155
+ * The config's prepare command (#481), run in a new ticket worktree before
156
+ * its baseline tests. Journaled only with a prepare command. A failed one
157
+ * leaves the baseline tests unrun.
158
+ */
159
+ const BaselinePreparedEntrySchema = z.object({
160
+ ...ENTRY_FIELDS,
161
+ kind: z.literal('baseline_prepared'),
162
+ content: z.object({ check: GateCheckSchema }),
163
+ })
164
+
154
165
  /** The tests as they stood before any agent worked on a ticket. */
155
166
  const BaselineTestsEntrySchema = z.object({
156
167
  ...ENTRY_FIELDS,
@@ -597,6 +608,7 @@ export const StuckReasonSchema = z.enum([
597
608
  'join_failed',
598
609
  'join_gates_failed',
599
610
  'install_failed',
611
+ 'prepare_failed',
600
612
  'setup_change_needed',
601
613
  'crashed',
602
614
  ])
@@ -772,8 +784,9 @@ export type ChangesetBump = z.infer<typeof ChangesetBumpSchema>
772
784
  /**
773
785
  * The engine committed the run's one changeset (#461) at `path` on the run
774
786
  * branch and pushed it (`sha` is the run branch's HEAD after). `packages`
775
- * are the ones it names (none for `release:none`). `path` is `null` when
776
- * the repo no longer had a changesets config, so nothing was written.
787
+ * are the ones it names. `path` is `null` when nothing was written: the
788
+ * spec is `release:none` (#476), or the repo no longer had a changesets
789
+ * config.
777
790
  */
778
791
  const ChangesetWrittenEntrySchema = z.object({
779
792
  ...ENTRY_FIELDS,
@@ -1111,6 +1124,7 @@ export const JournalEntrySchema = z.discriminatedUnion('kind', [
1111
1124
  TicketSnapshotEntrySchema,
1112
1125
  RunBranchCreatedEntrySchema,
1113
1126
  TicketWorktreeCreatedEntrySchema,
1127
+ BaselinePreparedEntrySchema,
1114
1128
  BaselineTestsEntrySchema,
1115
1129
  BaselineReusedEntrySchema,
1116
1130
  AgentStartedEntrySchema,
@@ -1185,6 +1199,7 @@ export const JournalRecordSchema = z.discriminatedUnion('kind', [
1185
1199
  TicketSnapshotEntrySchema.extend(STAMP_FIELDS),
1186
1200
  RunBranchCreatedEntrySchema.extend(STAMP_FIELDS),
1187
1201
  TicketWorktreeCreatedEntrySchema.extend(STAMP_FIELDS),
1202
+ BaselinePreparedEntrySchema.extend(STAMP_FIELDS),
1188
1203
  BaselineTestsEntrySchema.extend(STAMP_FIELDS),
1189
1204
  BaselineReusedEntrySchema.extend(STAMP_FIELDS),
1190
1205
  AgentStartedEntrySchema.extend(STAMP_FIELDS),
@@ -1258,6 +1273,7 @@ export const JournalKindSchema = z.enum([
1258
1273
  'ticket_snapshot',
1259
1274
  'run_branch_created',
1260
1275
  'ticket_worktree_created',
1276
+ 'baseline_prepared',
1261
1277
  'baseline_tests',
1262
1278
  'baseline_reused',
1263
1279
  'agent_started',
@@ -139,6 +139,8 @@ export type TicketProgress = {
139
139
  worktree: ReplayedWorktree | null
140
140
  /** The install in the new worktree; its `check` is `null` with no manifest. */
141
141
  install: ReplayedInstall | null
142
+ /** The prepare command run before its baseline tests (`baseline_prepared`). */
143
+ prepare: GateCheck | null
142
144
  /** Its own baseline test run, or the one it reused (`baseline_reused`). */
143
145
  baseline: TestRun | null
144
146
  /**
@@ -531,6 +533,7 @@ export type RunState = {
531
533
  export const EMPTY_TICKET_PROGRESS: TicketProgress = {
532
534
  worktree: null,
533
535
  install: null,
536
+ prepare: null,
534
537
  baseline: null,
535
538
  baseline_sha: null,
536
539
  test_writer: null,
@@ -1820,6 +1823,8 @@ const progressChange = ({
1820
1823
  return { worktree: record.content }
1821
1824
  case 'dependencies_installed':
1822
1825
  return { install: { check: record.content.check } }
1826
+ case 'baseline_prepared':
1827
+ return { prepare: record.content.check }
1823
1828
  case 'baseline_tests':
1824
1829
  return {
1825
1830
  baseline: record.content,
@@ -2092,8 +2097,9 @@ const retriedChange = ({
2092
2097
  * branch after a rebase), then the gates;
2093
2098
  * - in a review fix round, the round starts over as round 1 with fresh
2094
2099
  * fixers, so the user's changes are gated, committed, and re-reviewed;
2095
- * - a leftover scan, a failed install, a reviewer's failed tries, a
2096
- * failed join, or a step crashes cut off just run again.
2100
+ * - a leftover scan, a failed install, a failed prepare command at the
2101
+ * baseline, a reviewer's failed tries, a failed join, or a step crashes
2102
+ * cut off just run again.
2097
2103
  */
2098
2104
  const resumedProgress = ({
2099
2105
  progress,
@@ -2117,6 +2123,7 @@ const resumedProgress = ({
2117
2123
  rejoins: 0,
2118
2124
  install:
2119
2125
  progress.install?.check?.ok === false ? null : progress.install,
2126
+ prepare: progress.prepare?.ok === false ? null : progress.prepare,
2120
2127
  leftovers: {
2121
2128
  red: commits.red === null ? null : progress.leftovers.red,
2122
2129
  green: commits.green === null ? null : progress.leftovers.green,
@@ -2128,6 +2135,7 @@ const resumedProgress = ({
2128
2135
  if (
2129
2136
  reason === 'leftovers_found' ||
2130
2137
  reason === 'install_failed' ||
2138
+ reason === 'prepare_failed' ||
2131
2139
  reason === 'join_failed' ||
2132
2140
  reason === 'join_gates_failed' ||
2133
2141
  reason === 'crashed' ||
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alecsibilia/luca",
3
3
  "description": "Luca: turns a spec on GitHub Issues into a reviewed pull request, run from Paseo with Claude Code",
4
- "version": "14.0.0-alpha.0",
4
+ "version": "14.0.0-alpha.1",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Alec Sibilia <sibilia.alec@gmail.com>",
7
7
  "homepage": "https://github.com/asibilia/luca-framework#readme",