@alecsibilia/luca 14.0.0-alpha.0 → 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 (47) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +5 -7
  3. package/board/index.server.ts +4 -29
  4. package/board/package.json +1 -1
  5. package/board/server/board-vocabulary.ts +12 -1
  6. package/board/server/luca-version.ts +2 -0
  7. package/board/server/make-rows.ts +24 -2
  8. package/board/server/reduce-board.ts +42 -1
  9. package/board/shared/board-rpc.ts +2 -2
  10. package/engine/agents/role-instructions.ts +3 -1
  11. package/engine/agents/role-prompts.ts +17 -0
  12. package/engine/agents/role-results.ts +39 -5
  13. package/engine/cli/doctor-command.ts +1 -13
  14. package/engine/cli/init-command.ts +33 -1
  15. package/engine/cli/init.ts +27 -6
  16. package/engine/cli/luca.ts +3 -2
  17. package/engine/cli/repo-adapters-real.ts +19 -2
  18. package/engine/cli/setup.ts +24 -3
  19. package/engine/config/engine-config.ts +31 -0
  20. package/engine/core/already-done.ts +139 -0
  21. package/engine/core/changeset.ts +6 -7
  22. package/engine/core/decide-build.ts +133 -18
  23. package/engine/core/decide-usage.ts +5 -3
  24. package/engine/core/decide.ts +10 -4
  25. package/engine/core/execute-already-done.ts +65 -0
  26. package/engine/core/execute-build.ts +138 -24
  27. package/engine/core/execute-changeset.ts +28 -16
  28. package/engine/core/execute.ts +13 -1
  29. package/engine/core/learner-digest.ts +3 -1
  30. package/engine/core/not-in-run.ts +29 -0
  31. package/engine/core/pull-request-text.ts +45 -4
  32. package/engine/core/session-close.ts +10 -3
  33. package/engine/core/stuck-text.ts +7 -2
  34. package/engine/gates/gate-runner.ts +77 -6
  35. package/engine/gates/gate-schemas.ts +23 -2
  36. package/engine/gates/leftover-scan.ts +7 -2
  37. package/engine/git/git-adapter.ts +69 -4
  38. package/engine/git/path-text.ts +34 -0
  39. package/engine/guards/worktree-state.ts +14 -2
  40. package/engine/journal/journal-record.ts +80 -3
  41. package/engine/journal/replay.ts +51 -2
  42. package/engine/shell/run-command.ts +109 -7
  43. package/engine/testing/build-fixtures.ts +66 -0
  44. package/engine/tracker/github-tracker.ts +5 -0
  45. package/engine/tracker/in-memory-tracker.ts +3 -0
  46. package/engine/tracker/tracker.ts +5 -0
  47. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.2
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.1
8
+
9
+ ### Patch Changes
10
+
11
+ - 8a58510: Spec: Luca v14 fixes: no empty changesets, board version, init sets up the repo ([#480](https://github.com/asibilia/luca-framework/issues/480))
12
+
3
13
  ## 14.0.0-alpha.0
4
14
 
5
15
  ### 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.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' }),
@@ -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.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.',
@@ -49,11 +49,30 @@ 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.',
55
56
  }
56
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
+
57
76
  /** Why a retry of an edited ticket was refused, in words. */
58
77
  export const RETRY_REFUSED_TEXT =
59
78
  "Its new text or labels aren't ready to build."
@@ -375,6 +394,8 @@ const STEP_WORDS: Record<string, string> = {
375
394
  push_final_fixes: 'pushing the run branch',
376
395
  write_changeset: 'writing the changeset',
377
396
  open_pull_request: 'opening the pull request',
397
+ mark_already_done: 'noting the work is already done',
398
+ close_ticket: 'closing the ticket',
378
399
  }
379
400
 
380
401
  /**
@@ -1274,6 +1295,23 @@ const applyKind = ({
1274
1295
  ticket,
1275
1296
  because: record.content.because,
1276
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
1277
1315
  case 'ticket_retried':
1278
1316
  return ticketRetried({ state, record })
1279
1317
  // An ignored reply is only a chat row; the engine answered it.
@@ -1576,6 +1614,9 @@ const agentFinished = ({
1576
1614
  if (card.open_check === 'review') {
1577
1615
  return { ...done, activity: 'test findings answered' }
1578
1616
  }
1617
+ if (result.outcome === 'already_done') {
1618
+ return { ...done, activity: 'already done' }
1619
+ }
1579
1620
  return result.outcome === 'nothing_new_to_test'
1580
1621
  ? { ...done, step: 2, activity: 'nothing new to test' }
1581
1622
  : { ...done, step: 1, activity: 'red check' }
@@ -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(),
@@ -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
 
@@ -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.'
@@ -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)
@@ -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`